diff --git a/README.md b/README.md index 92fe74463..ea317699d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,21 @@ Install the dependencies and start a local instance using the following script: yarn start ``` +## Deploying the UI + +1. Build the js/css bundle by running `yarn build`. The build will appear in the `dist/` directory). +2. Modify the `index.html` to point to `dist/index.js` (instead of `example.js`). +3. Upload the following files to wherever you're deploying the UI: + - `index.html` (modified to point to `dist/index.js`) + - `example.css` + - `dist/` + - `index.js` + - `index.js.map` + - `index.css` + - `index.css.map` + +Note: only contents produced during build in the `dist/` directory are likely to change over time (the `index.html` and `example.css` files contain minimal code), so subsequent deployments will typically only need to replace the `dist/` contents. + ## Library Documentation More coming soon... diff --git a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap index 44a0fa7d0..47cd8838d 100644 --- a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap +++ b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap @@ -192,6 +192,7 @@ exports[`components > viewers > stop viewer should render countdown times after stopViewerConfig={ Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, } } @@ -265,7 +266,13 @@ exports[`components > viewers > stop viewer should render countdown times after
-
+
Stop ID @@ -489,345 +496,553 @@ exports[`components > viewers > stop viewer should render countdown times after
-
- +
+ -
-
-
- - 20 - - To - Gresham TC -
-
+ -
+
- + + 20 + + To + Gresham TC +
+
- - +
+ + + + + +
+
- - + > +
+ 52 min +
+
+
+ +
+
+ +
-
-
- 52 min -
-
-
-
-
- -
+
+ + +
+
- + +
-
- - -
+ + + + + + + 00:17 + +
+ +
+
@@ -928,6 +1143,7 @@ exports[`components > viewers > stop viewer should render countdown times for st stopViewerConfig={ Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, } } @@ -1001,7 +1217,13 @@ exports[`components > viewers > stop viewer should render countdown times for st
-
+
Stop ID @@ -1225,255 +1447,364 @@ exports[`components > viewers > stop viewer should render countdown times for st
-
- +
+ -
-
-
- - 20 - - To - Gresham TC -
-
+ -
+
- + + 20 + + To + Gresham TC +
+
- - +
+ + + + + +
+
- - + > +
+ 52 min +
+
+
+ +
+
+ +
-
-
- 52 min -
-
-
-
-
- -
+
+ + +
+
- + +
-
- - -
+ + + + + + + 00:17 + +
+ +
+
@@ -1673,6 +2004,7 @@ exports[`components > viewers > stop viewer should render times after midnight w stopViewerConfig={ Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, } } @@ -1746,7 +2078,13 @@ exports[`components > viewers > stop viewer should render times after midnight w
-
+
Stop ID @@ -1970,354 +2308,562 @@ exports[`components > viewers > stop viewer should render times after midnight w
-
- +
+ -
-
-
- - 20 - - To - Gresham TC -
-
+ -
+
- - + 20 + + To + Gresham TC +
+
+ - +
+ + + + + +
+
- - + > +
+ Thursday +
+
+ 00:51 +
+
+
+ +
+
+ +
-
-
- Thursday -
-
- 00:51 -
-
-
-
-
- -
+
+ + +
+
- + +
-
- - -
+ + + + + + + 00:17 + +
+ +
+
@@ -2775,6 +3321,7 @@ exports[`components > viewers > stop viewer should render with OTP transit index stopViewerConfig={ Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, } } @@ -2848,7 +3395,13 @@ exports[`components > viewers > stop viewer should render with OTP transit index
-
+
Stop ID @@ -3072,1096 +3625,1628 @@ exports[`components > viewers > stop viewer should render with OTP transit index
-
- +
+ -
-
-
- - 20 - - To - Gresham TC -
-
+ -
+
- + + 20 + + To + Gresham TC +
+
- - +
+ + + + + +
+
- - + > +
+ Monday +
+
+ 18:00 +
+
+
+ +
+
+ +
-
-
- Monday -
-
- 18:00 -
-
-
-
-
- -
-
- + + +
+
+ - -
- - -
-
- -
-
-
- - 36 - - To - Tualatin Park & Ride -
-
-
+
- - + 36 + + To + Tualatin Park & Ride +
+
+ - +
+ + + + + +
+
- - + > +
+ Tuesday +
+
+ 16:11 +
+
+
+ +
+
+ +
-
-
- Tuesday -
-
- 16:11 -
-
-
-
-
- -
-
- + + +
+ + - -
- - -
-
- -
-
-
- - 94 - - To - King City -
-
-
+
- - + 94 + + To + King City +
+
+ - +
+ + + + + +
+
- - + > +
+ Tuesday +
+
+ 15:22 +
+
+
+ +
+
+ +
-
-
- Tuesday -
-
- 15:22 -
-
-
-
-
- -
-
- + + +
+ + - -
- - -
-
- -
-
-
- - 94 - - To - Sherwood -
-
-
+
- - + 94 + + To + Sherwood +
+
+ - +
+ + + + + +
+
- - + > +
+ Tuesday +
+
+ 14:28 +
+
+
+ +
+
+ +
-
-
- Tuesday -
-
- 14:28 -
-
-
-
-
- -
+
+ + +
+
- + +
-
- - -
+ + + + + + + 17:50 + +
+ + + @@ -4614,6 +5699,7 @@ exports[`components > viewers > stop viewer should render with TriMet transit in stopViewerConfig={ Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, } } @@ -4687,7 +5773,13 @@ exports[`components > viewers > stop viewer should render with TriMet transit in
-
+
Stop ID @@ -4911,354 +6003,815 @@ exports[`components > viewers > stop viewer should render with TriMet transit in
-
- +
+ -
-
-
- - 20 - - To - Gresham TC -
-
+ -
+
- - + 20 + + To + Gresham TC +
+
+ - +
+ + + + + +
+
- - + > +
+ Monday +
+
+ 17:45 +
+
+
+ +
+
+ +
-
-
- Monday -
-
- 17:45 -
-
-
-
-
- -
+
+ + +
+
- + +
-
- - -
+ + + + + + + 17:38 + +
+ +
+
@@ -5293,6 +6846,7 @@ exports[`components > viewers > stop viewer should render with initial stop id a stopViewerConfig={ Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, } } diff --git a/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap b/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap index ae6a9f846..35365d63f 100644 --- a/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap +++ b/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap @@ -4,10 +4,14 @@ exports[`lib > reducers > create-otp-reducer should be able to create the initia Object { "activeSearchId": 0, "config": Object { - "autoPlan": false, + "autoPlan": Object { + "default": "ONE_LOCATION_CHANGED", + "mobile": "BOTH_LOCATIONS_CHANGED", + }, "debouncePlanTimeMs": 0, "homeTimezone": "America/Los_Angeles", "language": Object {}, + "onTimeThresholdSeconds": 60, "phoneFormatOptions": Object { "countryCode": "US", }, @@ -15,6 +19,7 @@ Object { "routingTypes": Array [], "stopViewer": Object { "numberOfDepartures": 3, + "showBlockIds": false, "timeRange": 345600, }, "transitOperators": Array [], @@ -50,7 +55,6 @@ Object { "wheelchair": false, }, "filter": Object { - "filter": "ALL", "sort": Object { "direction": "ASC", "type": null, diff --git a/__tests__/reducers/create-otp-reducer.js b/__tests__/reducers/create-otp-reducer.js index 05fcdf475..979268d0b 100644 --- a/__tests__/reducers/create-otp-reducer.js +++ b/__tests__/reducers/create-otp-reducer.js @@ -6,6 +6,6 @@ describe('lib > reducers > create-otp-reducer', () => { it('should be able to create the initial state', () => { setDefaultTestTime() - expect(getInitialState({}, {})).toMatchSnapshot() + expect(getInitialState({})).toMatchSnapshot() }) }) diff --git a/__tests__/test-utils/mock-data/store.js b/__tests__/test-utils/mock-data/store.js index 90e7a696e..724435f0d 100644 --- a/__tests__/test-utils/mock-data/store.js +++ b/__tests__/test-utils/mock-data/store.js @@ -22,10 +22,11 @@ const storeMiddleWare = [ * Get the initial stop of the redux reducer for otp-rr */ export function getMockInitialState () { - const mockConfig = {} - const mockInitialQuery = {} + const mockConfig = { + initialQuery: {} + } return clone({ - otp: getInitialState(mockConfig, mockInitialQuery), + otp: getInitialState(mockConfig), router: connectRouter(history) }) } diff --git a/example-config.yml b/example-config.yml index 2631baaca..921e180ac 100644 --- a/example-config.yml +++ b/example-config.yml @@ -15,6 +15,22 @@ api: # lon: -122.71607145667079 # name: Oregon Zoo, Portland, OR + +### Define the strategies for how to handle auto-planning of a new trip when +### different query parameters are changes in the form. The default config is +### shown below, but if autoPlan is set to false, auto-plan will never occur. +### Other strategies besides those shown below are: ANY (any changed param will +### cause a re-plan). +# autoPlan: +# mobile: BOTH_LOCATIONS_CHANGED +# default: ONE_LOCATION_CHANGED + +### The default query parameters can be overridden be uncommenting this object. +### Note: the override values must be valid values within otp-ui's query-params.js +# defaultQueryParams: +# maxWalkDistance: 3219 # 2 miles in meters + + ### The persistence setting is used to enable the storage of places (home, work), ### recent searches/places, user overrides, and favorite stops. ### Pick the strategy that best suits your needs. @@ -197,6 +213,26 @@ itinerary: # # ISO 2-letter country code for phone number formats (defaults to 'US') # countryCode: US +# set this value to change the absolute number of seconds of deviation from the +# schedule for a transit stop time to be considered early, on-time or late. The +# default is 60 seconds. +# onTimeThresholdSeconds: 60 + # Format the date time format for display. dateTime: longDateFormat: DD-MM-YYYY + +# stopViewer: +# # The max. number of departures to show for each trip pattern +# # in the stop viewer Next Arrivals mode +# # (defaults to 3 if unspecified). +# numberOfDepartures: 3 +# # Whether to display block IDs with each departure in the schedule mode. +# # (defaults to false if unspecified). +# showBlockIds: false +# # Specifies the time window, in seconds, in which to search for next arrivals, +# # so that, for example, if it is Friday and a route does +# # not begin service again until Monday, we are showing its next +# # departure and it is not entirely excluded from display +# # (defaults to 4 days/345600s if unspecified). +# timeRange: 345600 diff --git a/example.css b/example.css index 2f2f59ef0..9c3d962d7 100644 --- a/example.css +++ b/example.css @@ -31,10 +31,14 @@ padding: 0px 15px; } +/* Necessary for defining the height in the main panel nested elements. */ +.main-row main { + height: 100%; +} + .sidebar { height: 100%; padding: 10px; - overflow-y: auto; box-shadow: 3px 0px 12px #00000052; z-index: 1000; } diff --git a/example.js b/example.js index 1845f3ee5..33852b548 100644 --- a/example.js +++ b/example.js @@ -4,35 +4,40 @@ import 'es6-math' import {ClassicLegIcon, ClassicModeIcon} from '@opentripplanner/icons' import { createHashHistory } from 'history' import { connectRouter, routerMiddleware } from 'connected-react-router' -import React, { Component } from 'react' +import React from 'react' import { render } from 'react-dom' import { createStore, combineReducers, applyMiddleware, compose } from 'redux' import { Provider } from 'react-redux' import thunk from 'redux-thunk' import createLogger from 'redux-logger' -// import Bootstrap Grid components for layout -import { Grid, Row, Col } from 'react-bootstrap' - // import OTP-RR components import { + BatchResultsScreen, + BatchRoutingPanel, + BatchSearchScreen, CallTakerControls, CallTakerPanel, CallTakerWindows, DefaultItinerary, DefaultMainPanel, - DesktopNav, - BatchRoutingPanel, - Map, - MobileMain, + FieldTripWindows, + MobileResultsScreen, + MobileSearchScreen, ResponsiveWebapp, createCallTakerReducer, createOtpReducer, - createUserReducer + createUserReducer, + otpUtils } from './lib' // load the OTP configuration import otpConfig from './config.yml' +const isBatchRoutingEnabled = otpUtils.itinerary.isBatchRoutingEnabled( + otpConfig +) +const isCallTakerModuleEnabled = !!otpConfig.datastoreUrl + // Set useCustomIcons to true to override classic icons with the exports from // custom-icons.js const useCustomIcons = false @@ -47,20 +52,64 @@ if (useCustomIcons) { MyModeIcon = CustomIcons.CustomModeIcon } +// Stubs for terms of service/storage for development purposes only. +// They are required if otpConfig.persistence.strategy === 'otp_middleware' +// (otherwise, a "Content not found" box will be shown). +// These components should be placed in their own files with appropriate content. +const TermsOfService = () => ( + <> +

Terms of Service

+

Content for terms of service.

+ +) +const TermsOfStorage = () => ( + <> +

Terms of Storage

+

Content for terms of storage.

+ +) + // define some application-wide components that should be used in // various places. The following components can be provided here: +// - defaultMobileTitle (required) // - ItineraryBody (required) // - ItineraryFooter (optional) // - LegIcon (required) +// - MainControls (optional) +// - MainPanel (required) +// - MapWindows (optional) +// - MobileResultsScreen (required) +// - MobileSearchScreen (required) // - ModeIcon (required) +// - TermsOfService (required if otpConfig.persistence.strategy === 'otp_middleware') +// - TermsOfStorage (required if otpConfig.persistence.strategy === 'otp_middleware') const components = { + defaultMobileTitle: () =>
OpenTripPlanner
, ItineraryBody: DefaultItinerary, LegIcon: MyLegIcon, - ModeIcon: MyModeIcon + MainControls: isCallTakerModuleEnabled ? CallTakerControls : null, + MainPanel: isCallTakerModuleEnabled + ? CallTakerPanel + : isBatchRoutingEnabled + ? BatchRoutingPanel + : DefaultMainPanel, + MapWindows: isCallTakerModuleEnabled + ? () => <> + + + + : null, + MobileResultsScreen: isBatchRoutingEnabled + ? BatchResultsScreen + : MobileResultsScreen, + MobileSearchScreen: isBatchRoutingEnabled + ? BatchSearchScreen + : MobileSearchScreen, + ModeIcon: MyModeIcon, + TermsOfService, + TermsOfStorage } -// Get the initial query from config (for demo/testing purposes). -const {initialQuery} = otpConfig const history = createHashHistory() const middleware = [ thunk, @@ -76,109 +125,41 @@ if (process.env.NODE_ENV === 'development') { const store = createStore( combineReducers({ callTaker: createCallTakerReducer(), - otp: createOtpReducer(otpConfig, initialQuery), + otp: createOtpReducer(otpConfig), user: createUserReducer(), router: connectRouter(history) }), compose(applyMiddleware(...middleware)) ) -// define a simple responsive UI using Bootstrap and OTP-RR -class OtpRRExample extends Component { - render () { - /** desktop view **/ - const desktopView = ( -
- - - - - {/* - Note: the main tag provides a way for users of screen readers - to skip to the primary page content. - TODO: Find a better place. - */} -
- {/* TODO: extract the BATCH elements out of CallTakerPanel. */} - {otpConfig.datastoreUrl - ? - : otpConfig.routingTypes.find(t => t.key === 'BATCH') - ? - : - } -
- - {otpConfig.datastoreUrl ? : null} - - {otpConfig.datastoreUrl ? : null} - - -
-
-
- ) - - /** mobile view **/ - const mobileView = ( - //
Needed for accessibility checks. TODO: Find a better place. -
- } - title={
OpenTripPlanner
} - /> -
- ) - - /** - * The main webapp. - * - * Note: the ResponsiveWebapp creates a React context provider - * (./util/contexts#ComponentContext to be specific) to supply custom - * components to various other subcomponents throughout otp-react-redux. If - * the ResponsiveWebapp is not used and instead some subcomponents that use - * the components in the `components` variable are imported and rendered - * outside of the ResponsiveWebapp component, then the ComponentContext will - * need to wrap that component in order for the subcomponents to be able to - * access the component context. For example: - * - * ```js - * import RouteViewer from 'otp-react-redux/build/components/viewers/route-viewer' - * import { ComponentContext } from 'otp-react-redux/build/util/contexts' - * - * const components = { - * ModeIcon: MyCustomModeIconComponent - * } - * const ContextAwareRouteViewer = () => ( - * - * - * - * ) - * ``` - */ - return ( - - ) - } -} - // render the app render( ( - { /** - * If not using router history, simply include OtpRRExample here: - * e.g. - * - */ - } - + {/** + * Note: the ResponsiveWebapp creates a React context provider + * (./util/contexts#ComponentContext to be specific) to supply custom + * components to various other subcomponents throughout otp-react-redux. If + * the ResponsiveWebapp is not used and instead some subcomponents that use + * the components in the `components` variable are imported and rendered + * outside of the ResponsiveWebapp component, then the ComponentContext will + * need to wrap that component in order for the subcomponents to be able to + * access the component context. For example: + * + * ```js + * import RouteViewer from 'otp-react-redux/build/components/viewers/route-viewer' + * import { ComponentContext } from 'otp-react-redux/build/util/contexts' + * + * const components = { ModeIcon: MyCustomModeIconComponent } + * const ContextAwareRouteViewer = () => ( + * + * + * + * ) + * ``` + */} + - ) - , - + ), document.getElementById('root') ) diff --git a/lib/actions/api.js b/lib/actions/api.js index 2e5df722e..0bf3a442c 100644 --- a/lib/actions/api.js +++ b/lib/actions/api.js @@ -540,6 +540,7 @@ export function findGeometryForTrip (params) { ) } +const fetchingStopTimesForStop = createAction('FETCHING_STOP_TIMES_FOR_STOP') const findStopTimesForStopResponse = createAction('FIND_STOP_TIMES_FOR_STOP_RESPONSE') const findStopTimesForStopError = createAction('FIND_STOP_TIMES_FOR_STOP_ERROR') @@ -548,20 +549,32 @@ const findStopTimesForStopError = createAction('FIND_STOP_TIMES_FOR_STOP_ERROR') */ export function findStopTimesForStop (params) { return function (dispatch, getState) { - let { stopId, ...otherParams } = params + dispatch(fetchingStopTimesForStop(params)) + const { date, stopId, ...otherParams } = params + let datePath = '' + if (date) { + const dateWithoutDashes = date.replace(/-/g, '') + datePath = `/${dateWithoutDashes}` + } + // If other params not provided, fall back on defaults from stop viewer config. + // Note: query params don't apply with the OTP /date endpoint. const queryParams = { ...getStopViewerConfig(getState().otp), ...otherParams } - // If no start time is provided, pass in the current time. Note: this is not + + // If no start time is provided and no date is provided in params, + // pass in the current time. Note: this is not // a required param by the back end, but if a value is not provided, the // time defaults to the server's time, which can make it difficult to test // scenarios when you may want to use a different date/time for your local // testing environment. - if (!queryParams.startTime) { + if (!queryParams.startTime && !date) { const nowInSeconds = Math.floor((new Date()).getTime() / 1000) queryParams.startTime = nowInSeconds } + + // (Re-)fetch stop times for the stop. dispatch(createQueryAction( - `index/stops/${stopId}/stoptimes?${qs.stringify(queryParams)}`, + `index/stops/${stopId}/stoptimes${datePath}?${qs.stringify(queryParams)}`, findStopTimesForStopResponse, findStopTimesForStopError, { diff --git a/lib/actions/call-taker.js b/lib/actions/call-taker.js index 25ccb2ae7..5a48b8284 100644 --- a/lib/actions/call-taker.js +++ b/lib/actions/call-taker.js @@ -1,8 +1,9 @@ import { getUrlParams } from '@opentripplanner/core-utils/lib/query' +import { serialize } from 'object-to-formdata' import qs from 'qs' import { createAction } from 'redux-actions' -import {searchToQuery} from '../util/call-taker' +import {searchToQuery, sessionIsInvalid} from '../util/call-taker' import {URL_ROOT} from '../util/constants' import {getTimestamp} from '../util/state' @@ -29,12 +30,16 @@ export function endCall () { return function (dispatch, getState) { const {callTaker, otp} = getState() const {activeCall, session} = callTaker + const { sessionId } = session if (sessionIsInvalid(session)) return - // Make POST request to store call. - const callData = new FormData() - callData.append('sessionId', session.sessionId) - callData.append('call.startTime', activeCall.startTime) - callData.append('call.endTime', getTimestamp()) + // Make POST request to store new call. + const callData = serialize({ + sessionId, + call: { + startTime: activeCall.startTime, + endTime: getTimestamp() + } + }) fetch(`${otp.config.datastoreUrl}/calltaker/call`, {method: 'POST', body: callData} ) @@ -84,9 +89,8 @@ function newSession (datastoreUrl, verifyLoginUrl, redirect) { .then(res => res.json()) .then(data => { const {sessionId: session} = data - console.log('newSession success: ' + session) const windowUrl = `${verifyLoginUrl}?${qs.stringify({session, redirect})}` - console.log('redirecting to: ' + windowUrl) + // Redirect to login url. window.location = windowUrl }) .catch(error => { @@ -104,8 +108,8 @@ function checkSession (datastoreUrl, sessionId) { .then(res => res.json()) .then(session => dispatch(storeSession({session}))) .catch(error => { - console.error('checkSession error', error) dispatch(storeSession({session: null})) + alert(`Error establishing auth session: ${JSON.stringify(error)}`) }) } } @@ -120,28 +124,12 @@ export function fetchCalls () { if (sessionIsInvalid(callTaker.session)) return const {datastoreUrl} = otp.config const {sessionId} = callTaker.session - const limit = 10 + const limit = 30 fetch(`${datastoreUrl}/calltaker/call?${qs.stringify({limit, sessionId})}`) .then(res => res.json()) - .then(calls => { - console.log('GET calls response', calls) - dispatch(receivedCalls({calls})) - }) - .catch(err => { - alert(`Could not fetch calls: ${JSON.stringify(err)}`) - }) - } -} - -/** - * @return {boolean} - whether a calltaker session is invalid - */ -function sessionIsInvalid (session) { - if (!session || !session.sessionId) { - console.error('No valid OTP datastore session found.') - return true + .then(calls => dispatch(receivedCalls({calls}))) + .catch(err => alert(`Error fetching calls: ${JSON.stringify(err)}`)) } - return false } /** @@ -161,19 +149,13 @@ export function saveQueriesForCall (call) { const search = otp.searches[searchId] const query = searchToQuery(search, call, otp.config) const {sessionId} = callTaker.session - const queryData = new FormData() - queryData.append('sessionId', sessionId) - queryData.append('query.queryParams', query.queryParams) - queryData.append('query.fromPlace', query.fromPlace) - queryData.append('query.toPlace', query.toPlace) - queryData.append('query.timeStamp', query.timeStamp) - queryData.append('query.call.id', call.id) + const queryData = serialize({ sessionId, query }) return fetch(`${datastoreUrl}/calltaker/callQuery?sessionId=${sessionId}`, {method: 'POST', body: queryData} ) .then(res => res.json()) .catch(err => { - alert(`Could not fetch calls: ${JSON.stringify(err)}`) + alert(`Error storing call queries: ${JSON.stringify(err)}`) }) })) } @@ -195,7 +177,7 @@ export function fetchQueries (callId) { dispatch(receivedQueries({callId, queries})) }) .catch(err => { - alert(`Could not fetch calls: ${JSON.stringify(err)}`) + alert(`Error fetching queries: ${JSON.stringify(err)}`) }) } } diff --git a/lib/actions/field-trip.js b/lib/actions/field-trip.js new file mode 100644 index 000000000..69d072347 --- /dev/null +++ b/lib/actions/field-trip.js @@ -0,0 +1,322 @@ +import { planParamsToQueryAsync } from '@opentripplanner/core-utils/lib/query' +import { OTP_API_DATE_FORMAT, OTP_API_TIME_FORMAT } from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import { serialize } from 'object-to-formdata' +import qs from 'qs' +import { createAction } from 'redux-actions' + +import {routingQuery} from './api' +import {setQueryParam} from './form' +import {getGroupSize, getTripFromRequest, sessionIsInvalid} from '../util/call-taker' + +if (typeof (fetch) === 'undefined') require('isomorphic-fetch') + +/// PRIVATE ACTIONS + +const receivedFieldTrips = createAction('RECEIVED_FIELD_TRIPS') +const requestingFieldTrips = createAction('REQUESTING_FIELD_TRIPS') +const receivedFieldTripDetails = createAction('RECEIVED_FIELD_TRIP_DETAILS') +const requestingFieldTripDetails = createAction('REQUESTING_FIELD_TRIP_DETAILS') + +// PUBLIC ACTIONS + +export const setFieldTripFilter = createAction('SET_FIELD_TRIP_FILTER') +export const setActiveFieldTrip = createAction('SET_ACTIVE_FIELD_TRIP') +export const setGroupSize = createAction('SET_GROUP_SIZE') +export const toggleFieldTrips = createAction('TOGGLE_FIELD_TRIPS') + +/** + * Fetch all field trip requests (as summaries). + */ +export function fetchFieldTrips () { + return async function (dispatch, getState) { + dispatch(requestingFieldTrips()) + const {callTaker, otp} = getState() + if (sessionIsInvalid(callTaker.session)) return + const {datastoreUrl} = otp.config + const {sessionId} = callTaker.session + let fieldTrips = [] + try { + const res = await fetch(`${datastoreUrl}/fieldtrip/getRequestsSummary?${qs.stringify({sessionId})}`) + fieldTrips = await res.json() + } catch (e) { + alert(`Error fetching field trips: ${JSON.stringify(e)}`) + } + dispatch(receivedFieldTrips({fieldTrips})) + } +} + +/** + * Fetch details for a particular field trip request. + */ +export function fetchFieldTripDetails (requestId) { + return function (dispatch, getState) { + dispatch(requestingFieldTripDetails()) + const {callTaker, otp} = getState() + if (sessionIsInvalid(callTaker.session)) return + const {datastoreUrl} = otp.config + const {sessionId} = callTaker.session + fetch(`${datastoreUrl}/fieldtrip/getRequest?${qs.stringify({requestId, sessionId})}`) + .then(res => res.json()) + .then(fieldTrip => dispatch(receivedFieldTripDetails({fieldTrip}))) + .catch(err => { + alert(`Error fetching field trips: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Add note for field trip request. + */ +export function addFieldTripNote (request, note) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId, username} = callTaker.session + const noteData = serialize({ + sessionId, + note: {...note, userName: username}, + requestId: request.id + }) + return fetch(`${datastoreUrl}/fieldtrip/addNote`, + {method: 'POST', body: noteData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error adding field trip note: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Delete a specific note for a field trip request. + */ +export function deleteFieldTripNote (request, noteId) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + return fetch(`${datastoreUrl}/fieldtrip/deleteNote`, + {method: 'POST', body: serialize({ noteId, sessionId })} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error deleting field trip note: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Edit teacher (AKA submitter) notes for a field trip request. + */ +export function editSubmitterNotes (request, submitterNotes) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const noteData = serialize({ + notes: submitterNotes, + requestId: request.id, + sessionId + }) + return fetch(`${datastoreUrl}/fieldtrip/editSubmitterNotes`, + {method: 'POST', body: noteData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error editing submitter notes: ${JSON.stringify(err)}`) + }) + } +} + +export function saveRequestTrip (request, outbound, groupPlan) { + return function (dispatch, getState) { + // If plan is not valid, return before persisting trip. + const check = checkPlanValidity(request, groupPlan) + if (!check.isValid) return alert(check.message) + const requestOrder = outbound ? 0 : 1 + const type = outbound ? 'outbound' : 'inbound' + const preExistingTrip = getTripFromRequest(request, outbound) + if (preExistingTrip) { + const msg = `This action will overwrite a previously planned ${type} itinerary for this request. Do you wish to continue?` + if (!confirm(msg)) return + } + alert(`TODO: Save trip in request order ${requestOrder}!`) + // TODO: Enable saveTrip + // dispatch(saveTrip(request, requestOrder)) + } +} + +/** + * @typedef {Object} ValidationCheck + * @property {boolean} isValid - Whether the check is valid + * @property {string} message - The message explaining why the check returned + * invalid. + */ + +/** + * Checks that a group plan is valid for a given request, i.e., that it occurs + * on the requested travel date. + * @param request field trip request + * @param groupPlan the group plan to check + * @return {ValidationCheck} + */ +function checkPlanValidity (request, groupPlan) { + if (groupPlan == null) { + return { + isValid: false, + message: 'No active plan to save' + } + } + + // FIXME: add back in offset? + const planDeparture = moment(groupPlan.earliestStartTime) // .add('hours', otp.config.timeOffset) + const requestDate = moment(request.travelDate) + + if ( + planDeparture.date() !== requestDate.date() || + planDeparture.month() !== requestDate.month() || + planDeparture.year() !== requestDate.year() + ) { + return { + isValid: false, + message: `Planned trip date (${planDeparture.format('MM/DD/YYYY')}) is not the requested day of travel (${requestDate.format('MM/DD/YYYY')})` + } + } + + // FIXME More checks? E.g., origin/destination + + return { isValid: true, message: null } +} + +export function planTrip (request, outbound) { + return async function (dispatch, getState) { + dispatch(setGroupSize(getGroupSize(request))) + const trip = getTripFromRequest(request, outbound) + if (!trip) { + // Construct params from request details + if (outbound) dispatch(planOutbound(request)) + else dispatch(planInbound(request)) + } else { + // Populate params from saved query params + const params = await planParamsToQueryAsync(JSON.parse(trip.queryParams)) + dispatch(setQueryParam(params, trip.id)) + } + } +} + +function planOutbound (request) { + return async function (dispatch, getState) { + const {config} = getState().otp + // clearTrip() + const locations = await planParamsToQueryAsync({ + fromPlace: request.startLocation, + toPlace: request.endLocation + }, config) + const queryParams = { + date: moment(request.travelDate).format(OTP_API_DATE_FORMAT), + departArrive: 'ARRIVE', + groupSize: getGroupSize(request), + time: moment(request.arriveDestinationTime).format(OTP_API_TIME_FORMAT), + ...locations + } + dispatch(setQueryParam(queryParams)) + dispatch(routingQuery()) + } +} + +function planInbound (request) { + return async function (dispatch, getState) { + const {config} = getState().otp + const locations = await planParamsToQueryAsync({ + fromPlace: request.endLocation, + toPlace: request.startLocation + }, config) + // clearTrip() + const queryParams = { + date: moment(request.travelDate).format(OTP_API_DATE_FORMAT), + departArrive: 'DEPART', + groupSize: getGroupSize(request), + time: moment(request.leaveDestinationTime).format(OTP_API_TIME_FORMAT), + ...locations + } + dispatch(setQueryParam(queryParams)) + dispatch(routingQuery()) + } +} + +/** + * Set group size for a field trip request. Group size consists of numStudents, + * numFreeStudents, and numChaperones. + */ +export function setRequestGroupSize (request, groupSize) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const groupSizeData = serialize({ + ...groupSize, + requestId: request.id, + sessionId + }) + return fetch(`${datastoreUrl}/fieldtrip/setRequestGroupSize`, + {method: 'POST', body: groupSizeData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error setting group size: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Set payment info for a field trip request. + */ +export function setRequestPaymentInfo (request, paymentInfo) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const paymentData = serialize({ + ...paymentInfo, + requestId: request.id, + sessionId + }) + return fetch(`${datastoreUrl}/fieldtrip/setRequestPaymentInfo`, + {method: 'POST', body: paymentData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error setting payment info: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Set field trip request status (e.g., cancelled). + */ +export function setRequestStatus (request, status) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const statusData = serialize({ + requestId: request.id, + sessionId, + status + }) + return fetch(`${datastoreUrl}/fieldtrip/setRequestStatus`, + {method: 'POST', body: statusData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error setting request status: ${JSON.stringify(err)}`) + }) + } +} diff --git a/lib/actions/form.js b/lib/actions/form.js index 0e2688b74..e08ed6cd6 100644 --- a/lib/actions/form.js +++ b/lib/actions/form.js @@ -78,48 +78,50 @@ export function parseUrlQueryString (params = getUrlParams()) { let debouncedPlanTrip // store as variable here, so it can be reused. let lastDebouncePlanTimeMs +/** + * This action is dispatched when a change between the old query and new query + * is detected. It handles checks for whether the trip should be replanned + * (based on autoPlan strategies) as well as updating the UI state (esp. for + * mobile). + */ export function formChanged (oldQuery, newQuery) { return function (dispatch, getState) { const otpState = getState().otp + const { config, currentQuery, ui } = otpState + const { autoPlan, debouncePlanTimeMs } = config const isMobile = coreUtils.ui.isMobile() - + const { + fromChanged, + oneLocationChanged, + shouldReplanTrip, + toChanged + } = checkShouldReplanTrip(autoPlan, isMobile, oldQuery, newQuery) // If departArrive is set to 'NOW', update the query time to current - if (otpState.currentQuery && otpState.currentQuery.departArrive === 'NOW') { - dispatch(settingQueryParam({ time: moment().format(coreUtils.time.OTP_API_TIME_FORMAT) })) + if (currentQuery.departArrive === 'NOW') { + const now = moment().format(coreUtils.time.OTP_API_TIME_FORMAT) + dispatch(settingQueryParam({ time: now })) } - - // Determine if either from/to location has changed - const fromChanged = !isEqual(oldQuery.from, newQuery.from) - const toChanged = !isEqual(oldQuery.to, newQuery.to) - // Only clear the main panel if a single location changed. This prevents // clearing the panel on load if the app is focused on a stop viewer but a // search query should also be visible. - const oneLocationChanged = (fromChanged && !toChanged) || (!fromChanged && toChanged) if (oneLocationChanged) { dispatch(setMainPanelContent(null)) } - - // Clear the current search and return to search screen on mobile when - // either location changes only if not currently on welcome screen (otherwise - // when the current position is auto-set the screen will change unexpectedly). - if ( - isMobile && - (fromChanged || toChanged) && - otpState.ui.mobileScreen !== MobileScreens.WELCOME_SCREEN - ) { - dispatch(clearActiveSearch()) - dispatch(setMobileScreen(MobileScreens.SEARCH_FORM)) - } - - // Check whether a trip should be auto-replanned - const { autoPlan, debouncePlanTimeMs } = otpState.config - const updatePlan = - autoPlan || - (!isMobile && oneLocationChanged) || // TODO: make autoplan configurable at the parameter level? - (isMobile && fromChanged && toChanged) - if (updatePlan && queryIsValid(otpState)) { // trip plan should be made - // check if debouncing function needs to be (re)created + if (!shouldReplanTrip) { + // If not replanning the trip, clear the current search when either + // location changes. + if (fromChanged || toChanged) { + dispatch(clearActiveSearch()) + // Return to search screen on mobile only if not currently on welcome + // screen (otherwise when the current position is auto-set the screen + // will change unexpectedly). + if (ui.mobileScreen !== MobileScreens.WELCOME_SCREEN) { + dispatch(setMobileScreen(MobileScreens.SEARCH_FORM)) + } + } + } else if (queryIsValid(otpState)) { + // If replanning trip and query is valid, + // check if debouncing function needs to be (re)created. if (!debouncedPlanTrip || lastDebouncePlanTimeMs !== debouncePlanTimeMs) { debouncedPlanTrip = debounce(() => dispatch(routingQuery()), debouncePlanTimeMs) lastDebouncePlanTimeMs = debouncePlanTimeMs @@ -128,3 +130,50 @@ export function formChanged (oldQuery, newQuery) { } } } + +/** + * Check if the trip should be replanned based on the auto plan strategy, + * whether the mobile view is active, and the old/new queries. Response type is + * an object containing various booleans. + */ +export function checkShouldReplanTrip (autoPlan, isMobile, oldQuery, newQuery) { + // Determine if either from/to location has changed + const fromChanged = !isEqual(oldQuery.from, newQuery.from) + const toChanged = !isEqual(oldQuery.to, newQuery.to) + const oneLocationChanged = (fromChanged && !toChanged) || (!fromChanged && toChanged) + // Check whether a trip should be auto-replanned + const strategy = isMobile && autoPlan?.mobile + ? autoPlan?.mobile + : autoPlan?.default + const shouldReplanTrip = evaluateAutoPlanStrategy( + strategy, + fromChanged, + toChanged, + oneLocationChanged + ) + return { + fromChanged, + oneLocationChanged, + shouldReplanTrip, + toChanged + } +} + +/** + * Shorthand method to evaluate auto plan strategy. It is assumed that this is + * being called within the context of the `formChanged` action, so presumably + * some query param has already changed. If further checking of query params is + * needed, additional strategies should be added. + */ +const evaluateAutoPlanStrategy = (strategy, fromChanged, toChanged, oneLocationChanged) => { + switch (strategy) { + case 'ONE_LOCATION_CHANGED': + if (oneLocationChanged) return true + break + case 'BOTH_LOCATIONS_CHANGED': + if (fromChanged && toChanged) return true + break + case 'ANY': return true + default: return false + } +} diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 27cca104a..955515798 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -3,9 +3,13 @@ import coreUtils from '@opentripplanner/core-utils' import { createAction } from 'redux-actions' import { matchPath } from 'react-router' -import { findRoute } from './api' +import { findRoute, setUrlSearch } from './api' import { setMapCenter, setMapZoom, setRouterId } from './config' -import { clearActiveSearch, parseUrlQueryString, setActiveSearch } from './form' +import { + clearActiveSearch, + parseUrlQueryString, + setActiveSearch +} from './form' import { clearLocation } from './map' import { setActiveItinerary } from './narrative' import { getUiUrlParams } from '../util/state' @@ -230,3 +234,81 @@ export const MobileScreens = { SET_DATETIME: 7, RESULTS_SUMMARY: 8 } + +/** + * Enum to describe the layout of the itinerary view + * (currently only used in batch results). + */ +export const ItineraryView = { + DEFAULT: 'list', + /** One itinerary is shown. (In mobile view, the map is hidden.) */ + FULL: 'full', + /** One itinerary is shown, itinerary and map are focused on a leg. (The mobile view is split.) */ + LEG: 'leg', + /** One itinerary leg is hidden. (In mobile view, the map is expanded.) */ + LEG_HIDDEN: 'leg-hidden', + /** The list of itineraries is shown. (The mobile view is split.) */ + LIST: 'list', + /** The list of itineraries is hidden. (In mobile view, the map is expanded.) */ + LIST_HIDDEN: 'list-hidden' +} + +const setPreviousItineraryView = createAction('SET_PREVIOUS_ITINERARY_VIEW') + +/** + * Sets the itinerary view state (see values above) in the URL params + * (currently only used in batch results). + */ +export function setItineraryView (value) { + return function (dispatch, getState) { + const urlParams = coreUtils.query.getUrlParams() + const prevItineraryView = urlParams.ui_itineraryView || ItineraryView.DEFAULT + + // If the itinerary value is changed, + // set the desired ui query param, or remove it if same as default, + // and store the current view as previousItineraryView. + if (value !== urlParams.ui_itineraryView) { + if (value !== ItineraryView.DEFAULT) { + urlParams.ui_itineraryView = value + } else if (urlParams.ui_itineraryView) { + delete urlParams.ui_itineraryView + } + + dispatch(setUrlSearch(urlParams)) + dispatch(setPreviousItineraryView(prevItineraryView)) + } + } +} + +/** + * Switch the mobile batch results view between full map view and the split state + * (itinerary list or itinerary leg view) that was in place prior. + */ +export function toggleBatchResultsMap () { + return function (dispatch, getState) { + const urlParams = coreUtils.query.getUrlParams() + const itineraryView = urlParams.ui_itineraryView || ItineraryView.DEFAULT + + if (itineraryView === ItineraryView.LEG) { + dispatch(setItineraryView(ItineraryView.LEG_HIDDEN)) + } else if (itineraryView === ItineraryView.LIST) { + dispatch(setItineraryView(ItineraryView.LIST_HIDDEN)) + } else { + const { previousItineraryView } = getState().otp.ui + dispatch(setItineraryView(previousItineraryView)) + } + } +} + +/** + * Takes the user back to the mobile search screen in mobile views. + */ +export function showMobileSearchScreen () { + return function (dispatch, getState) { + // Reset itinerary view state to show the list of results *before* clearing the search. + // (Otherwise, if the map is expanded, the search is not cleared.) + dispatch(setItineraryView(ItineraryView.LIST)) + dispatch(clearActiveSearch()) + dispatch(setMobileScreen(MobileScreens.SEARCH_FORM)) + } +} diff --git a/lib/actions/user.js b/lib/actions/user.js index 8fdf799eb..67fe1911b 100644 --- a/lib/actions/user.js +++ b/lib/actions/user.js @@ -8,9 +8,10 @@ import { createAction } from 'redux-actions' import { routingQuery } from './api' import { setQueryParam } from './form' import { routeTo } from './ui' -import { TRIPS_PATH } from '../util/constants' +import { TRIPS_PATH, URL_ROOT } from '../util/constants' import { secureFetch } from '../util/middleware' -import { isNewUser } from '../util/user' +import { isBlank } from '../util/ui' +import { isNewUser, positionHomeAndWorkFirst } from '../util/user' // Middleware API paths. const API_MONITORED_TRIP_PATH = '/api/secure/monitoredtrip' @@ -78,13 +79,18 @@ export function fetchAuth0Token (auth0) { } /** - * Updates the redux state with the provided user data, - * and also fetches monitored trips if requested, i.e. when + * Updates the redux state with the provided user data, including + * placing the Home and Work locations at the beginning of the list + * of saved places for rendering in several UI components. + * + * Also, fetches monitored trips if requested, i.e. when * - initializing the user state with an existing persisted user, or * - POST-ing a user for the first time. */ function setUser (user, fetchTrips) { return function (dispatch, getState) { + positionHomeAndWorkFirst(user) + dispatch(setCurrentUser(user)) if (fetchTrips) { @@ -129,7 +135,8 @@ export function fetchOrInitializeUser (auth0User) { const isNewAccount = status === 'error' || (user && user.result === 'ERR') const userData = isNewAccount ? createNewUser(auth0User) : user - // Set uset in redux state. (Fetch trips for existing users.) + // Set user in redux state. + // (This sorts saved places, and, for existing users, fetches trips.) dispatch(setUser(userData, !isNewAccount)) } } @@ -146,6 +153,11 @@ export function createOrUpdateUser (userData, silentOnSuccess = false) { const { id } = userData // Middleware ID, NOT auth0 (or similar) id. let requestUrl, method + // Before persisting, filter out entries from userData.savedLocations with blank addresses. + userData.savedLocations = userData.savedLocations.filter( + ({ address }) => !isBlank(address) + ) + // Determine URL and method to use. const isCreatingUser = isNewUser(loggedInUser) if (isCreatingUser) { @@ -156,19 +168,47 @@ export function createOrUpdateUser (userData, silentOnSuccess = false) { method = 'PUT' } - const { data, message, status } = await secureFetch(requestUrl, accessToken, apiKey, method, { + const { data: returnedUser, message, status } = await secureFetch(requestUrl, accessToken, apiKey, method, { body: JSON.stringify(userData) }) // TODO: improve the UI feedback messages for this. - if (status === 'success' && data) { + if (status === 'success' && returnedUser) { if (!silentOnSuccess) { alert('Your preferences have been saved.') } // Update application state with the user entry as saved - // (as returned) by the middleware. (Fetch trips if creating user.) - dispatch(setUser(data, isCreatingUser)) + // (as returned) by the middleware. + // (This sorts saved places, and, for existing users, fetches trips.) + dispatch(setUser(returnedUser, isCreatingUser)) + } else { + alert(`An error was encountered:\n${JSON.stringify(message)}`) + } + } +} + +/** + * Deletes a user (and their corresponding trips, requests, etc.) from the + * middleware database. + * @param userData the user account to delete + * @param auth0 auth0 object (gives access to logout function) + */ +export function deleteUser (userData, auth0) { + return async function (dispatch, getState) { + const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) + const { id } = userData // Middleware ID, NOT auth0 (or similar) id. + const { data: deletedUser, message, status } = await secureFetch( + `${apiBaseUrl}${API_OTPUSER_PATH}/${id}`, + accessToken, + apiKey, + 'DELETE' + ) + // TODO: improve the UI feedback messages for this. + if (status === 'success' && deletedUser) { + alert(`Your user account (${userData.email}) has been deleted.`) + // Log out user and route them to the home page. + auth0.logout({returnTo: URL_ROOT}) } else { alert(`An error was encountered:\n${JSON.stringify(message)}`) } @@ -291,6 +331,7 @@ export function toggleSnoozeTrip (trip) { export function confirmAndDeleteUserMonitoredTrip (tripId) { return async function (dispatch, getState) { if (!confirm('Would you like to remove this trip?')) return + const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) const requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}/${tripId}` @@ -417,3 +458,35 @@ export function planNewTripFromMonitoredTrip (monitoredTrip) { }, 300) } } + +/** + * Saves the given place data at the specified index for the logged-in user. + * Note: places with blank addresses will not appear in persistence. + */ +export function saveUserPlace (placeToSave, placeIndex) { + return function (dispatch, getState) { + const { loggedInUser } = getState().user + + if (placeIndex === 'new') { + loggedInUser.savedLocations.push(placeToSave) + } else { + loggedInUser.savedLocations[placeIndex] = placeToSave + } + + dispatch(createOrUpdateUser(loggedInUser, true)) + } +} + +/** + * Delete the place data at the specified index for the logged-in user. + */ +export function deleteUserPlace (placeIndex) { + return function (dispatch, getState) { + if (!confirm('Would you like to remove this place?')) return + + const { loggedInUser } = getState().user + loggedInUser.savedLocations.splice(placeIndex, 1) + + dispatch(createOrUpdateUser(loggedInUser, true)) + } +} diff --git a/lib/components/admin/call-record.js b/lib/components/admin/call-record.js index 602599109..ff8ae8ab4 100644 --- a/lib/components/admin/call-record.js +++ b/lib/components/admin/call-record.js @@ -38,7 +38,7 @@ export default class CallRecord extends Component { style={{color: RED, fontSize: '8px', verticalAlign: '2px'}} type='circle' className='animate-flicker' /> - + {' '} [Active call] diff --git a/lib/components/admin/call-taker-controls.js b/lib/components/admin/call-taker-controls.js index d5b8f1833..a5f4ff803 100644 --- a/lib/components/admin/call-taker-controls.js +++ b/lib/components/admin/call-taker-controls.js @@ -1,30 +1,39 @@ import React, { Component } from 'react' import { connect } from 'react-redux' +import * as apiActions from '../../actions/api' import * as callTakerActions from '../../actions/call-taker' -import { routingQuery } from '../../actions/api' -import { setMainPanelContent } from '../../actions/ui' -import CallTimeCounter from './call-time-counter' +import * as fieldTripActions from '../../actions/field-trip' +import * as uiActions from '../../actions/ui' import Icon from '../narrative/icon' - -const RED = '#C35134' -const BLUE = '#1C4D89' -const GREEN = '#6B931B' +import { + CallHistoryButton, + CallTimeCounter, + ControlsContainer, + FieldTripsButton, + ToggleCallButton +} from './styled' /** * This component displays the controls for the Call Taker/Field Trip modules, * including: * - start/end call button * - view call list - * TODO * - view field trip list */ class CallTakerControls extends Component { - componentWillReceiveProps (nextProps) { - const {session} = nextProps + componentDidUpdate (prevProps) { + const { + callTakerEnabled, + fetchCalls, + fetchFieldTrips, + fieldTripEnabled, + session + } = this.props // Once session is available, fetch calls. - if (session && !this.props.session) { - this.props.fetchCalls() + if (session && !prevProps.session) { + if (callTakerEnabled) fetchCalls() + if (fieldTripEnabled) fetchFieldTrips() } } @@ -33,7 +42,7 @@ class CallTakerControls extends Component { else this.props.beginCall() } - _renderCallButton = () => { + _renderCallButtonIcon = () => { // Show stop button if call not in progress. if (this._callInProgress()) { return ( @@ -57,74 +66,57 @@ class CallTakerControls extends Component { _onToggleCallHistory = () => this.props.toggleCallHistory() + _onToggleFieldTrips = () => this.props.toggleFieldTrips() + _callInProgress = () => Boolean(this.props.activeCall) render () { - const {session} = this.props + const {callTakerEnabled, fieldTripEnabled, session} = this.props // If no valid session is found, do not show calltaker controls. if (!session) return null - // FIXME: styled component - const circleButtonStyle = { - position: 'absolute', - zIndex: 999999, - color: 'white', - borderRadius: '50%', - border: 'none', - boxShadow: '2px 2px 4px #000000' - } return ( - <> + {/* Start/End Call button */} - + {callTakerEnabled && + + {this._renderCallButtonIcon()} + + } {this._callInProgress() - ? + ? : null } {/* Call History toggle button */} - - {/* Field Trip toggle button TODO */} - + {callTakerEnabled && + + + + } + {/* Field Trip toggle button */} + {fieldTripEnabled && + + + + } + ) } } @@ -132,19 +124,21 @@ class CallTakerControls extends Component { const mapStateToProps = (state, ownProps) => { return { activeCall: state.callTaker.activeCall, + callTakerEnabled: Boolean(state.otp.config.modules.find(m => m.id === 'call')), + fieldTripEnabled: Boolean(state.otp.config.modules.find(m => m.id === 'ft')), session: state.callTaker.session } } -const {beginCall, endCall, fetchCalls, toggleCallHistory} = callTakerActions - const mapDispatchToProps = { - beginCall, - endCall, - fetchCalls, - routingQuery, - setMainPanelContent, - toggleCallHistory + beginCall: callTakerActions.beginCall, + endCall: callTakerActions.endCall, + fetchCalls: callTakerActions.fetchCalls, + fetchFieldTrips: fieldTripActions.fetchFieldTrips, + routingQuery: apiActions.routingQuery, + setMainPanelContent: uiActions.setMainPanelContent, + toggleCallHistory: callTakerActions.toggleCallHistory, + toggleFieldTrips: fieldTripActions.toggleFieldTrips } export default connect(mapStateToProps, mapDispatchToProps)(CallTakerControls) diff --git a/lib/components/admin/call-taker-windows.js b/lib/components/admin/call-taker-windows.js index b575cc2ca..a169c2a09 100644 --- a/lib/components/admin/call-taker-windows.js +++ b/lib/components/admin/call-taker-windows.js @@ -5,6 +5,7 @@ import * as callTakerActions from '../../actions/call-taker' import CallRecord from './call-record' import DraggableWindow from './draggable-window' import Icon from '../narrative/icon' +import {WindowHeader} from './styled' /** * Collects the various draggable windows used in the Call Taker module to @@ -12,40 +13,33 @@ import Icon from '../narrative/icon' */ class CallTakerWindows extends Component { render () { - const {callTaker, fetchQueries, searches} = this.props + const {callTaker, fetchQueries, searches, toggleCallHistory} = this.props const {activeCall, callHistory} = callTaker + if (!callHistory.visible) return null return ( - <> - {callHistory.visible - // Active call window - ? Call history} - onClickClose={this.props.toggleCallHistory} - > - {activeCall - ? - : null - } - {callHistory.calls.data.length > 0 - ? callHistory.calls.data.map((call, i) => ( - - )) - :
No calls in history
- } -
+ Call history} + onClickClose={toggleCallHistory} + style={{right: '15px', top: '50px'}} + > + {activeCall + ? : null } - + {callHistory.calls.data.length > 0 + ? callHistory.calls.data.map((call, i) => ( + + )) + :
No calls in history
+ } +
) } } @@ -58,11 +52,9 @@ const mapStateToProps = (state, ownProps) => { } } -const { - fetchQueries, - toggleCallHistory -} = callTakerActions - -const mapDispatchToProps = { fetchQueries, toggleCallHistory } +const mapDispatchToProps = { + fetchQueries: callTakerActions.fetchQueries, + toggleCallHistory: callTakerActions.toggleCallHistory +} export default connect(mapStateToProps, mapDispatchToProps)(CallTakerWindows) diff --git a/lib/components/admin/call-time-counter.js b/lib/components/admin/call-time-counter.js index 1d8dfe763..1383687a4 100644 --- a/lib/components/admin/call-time-counter.js +++ b/lib/components/admin/call-time-counter.js @@ -43,9 +43,14 @@ export default class CallTimeCounter extends Component { } render () { - const {className, style} = this.props + const {className} = this.props return ( -
+
{this._formatSeconds(this.state.counterString)}
) diff --git a/lib/components/admin/draggable-window.js b/lib/components/admin/draggable-window.js index 1159cdd39..a43789139 100644 --- a/lib/components/admin/draggable-window.js +++ b/lib/components/admin/draggable-window.js @@ -7,30 +7,34 @@ const noop = () => {} export default class DraggableWindow extends Component { render () { - const {children, draggableProps, header} = this.props + const { + children, + draggableProps, + footer, + header, + height = '245px', + onClickClose, + style + } = this.props const GREY_BORDER = '#777 1.3px solid' return (
{header}
{children}
+ {footer && +
+ {footer} +
+ }
) diff --git a/lib/components/admin/editable-section.js b/lib/components/admin/editable-section.js new file mode 100644 index 000000000..e5b8cf27f --- /dev/null +++ b/lib/components/admin/editable-section.js @@ -0,0 +1,147 @@ +import { Field, Form, Formik } from 'formik' +import React, {Component} from 'react' + +import { + Button, + Para, + Val +} from './styled' + +/** + * This component will render a set of fields along with a 'Change/Edit' button + * that toggles an editable state. In its editable state, the fields/values are + * rendered as form inputs, which can be edited and then saved/persisted. + */ +export default class EditableSection extends Component { + state = { + isEditing: false + } + + _exists = (val) => val !== null && typeof val !== 'undefined' + + _getVal = (fieldName) => this._exists(this.props.request[fieldName]) + ? this.props.request[fieldName] + : '' + + _onClickSave = data => { + const {request, onChange} = this.props + // Convert all null values received to '', + // otherwise they will appear in the backend as the 'null' string. + for (const field in data) { + if (data[field] === null) data[field] = '' + } + + onChange(request, data) + this.setState({isEditing: false}) + } + + _toggleEditing = () => { + this.setState({isEditing: !this.state.isEditing}) + } + + render () { + const {children, fields, inputStyle, request, valueFirst} = this.props + const {isEditing} = this.state + if (!request) return null + return ( + + { + formikProps => ( +
+ {children} + + {!isEditing + ? + : <> + + + + } + + {fields.map(f => { + const input = ( + + ) + return ( + + {valueFirst + ? <>{input} {f.label} + : <>{f.label}: {input} + } + + ) + })} + + ) + } +
+ ) + } +} + +/** + * This component renders either the specified value for a given field or, if + * in an active editing state, the associated field's respective input (e.g. + * text, number, or select). + */ +class InputToggle extends Component { + render () { + const {inputProps, fieldName, isEditing, options, style, value} = this.props + if (isEditing) { + if (options) { + return ( + + {Object.keys(options).map(k => + + )} + + ) + } else { + return + } + } + return {options ? options[value] : value} + } +} diff --git a/lib/components/admin/field-trip-details.js b/lib/components/admin/field-trip-details.js new file mode 100644 index 000000000..91d2f2af6 --- /dev/null +++ b/lib/components/admin/field-trip-details.js @@ -0,0 +1,233 @@ +import { getDateFormat } from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import React, { Component } from 'react' +import { DropdownButton, MenuItem } from 'react-bootstrap' +import { connect } from 'react-redux' +import styled from 'styled-components' + +import * as fieldTripActions from '../../actions/field-trip' +import DraggableWindow from './draggable-window' +import EditableSection from './editable-section' +import FieldTripNotes from './field-trip-notes' +import Icon from '../narrative/icon' +import { + Bold, + Button, + Container, + FullWithMargin, + Half, + Header, + InlineHeader, + Para, + Text, + Val, + WindowHeader as DefaultWindowHeader +} from './styled' +import TripStatus from './trip-status' +import Updatable from './updatable' +import { + getGroupSize, + GROUP_FIELDS, + PAYMENT_FIELDS, + TICKET_TYPES +} from '../../util/call-taker' + +const WindowHeader = styled(DefaultWindowHeader)` + margin-bottom: 0px; +` + +/** + * Shows the details for the active Field Trip Request. + */ +class FieldTripDetails extends Component { + _editSubmitterNotes = (val) => this.props.editSubmitterNotes(this.props.request, val) + + _getRequestLink = (path, isPublic = false) => + `${this.props.datastoreUrl}/${isPublic ? 'public/' : ''}fieldtrip/${path}?requestId=${this.props.request.id}` + + _onCloseActiveFieldTrip = () => this.props.setActiveFieldTrip(null) + + _onToggleStatus = () => { + const {request, setRequestStatus} = this.props + if (request.status !== 'cancelled') { + if (confirm('Are you sure you want to cancel this request? Any associated trips will be deleted.')) { + setRequestStatus(request, 'cancelled') + } + } else { + setRequestStatus(request, 'active') + } + } + + _renderFooter = () => { + const cancelled = this.props.request.status === 'cancelled' + return ( +
+ + + Feedback link + + + Receipt link + + + +
+ ) + } + + _renderHeader = () => { + const {dateFormat, request} = this.props + const { + id, + schoolName, + travelDate + } = request + const travelDateAsMoment = moment(travelDate) + return ( + + {schoolName} Trip (#{id}) +
+ + Travel date: {travelDateAsMoment.format(dateFormat)}{' '} + ({travelDateAsMoment.fromNow()}) + +
+
+ ) + } + + render () { + const { + addFieldTripNote, + deleteFieldTripNote, + request, + setRequestGroupSize, + setRequestPaymentInfo, + style + } = this.props + if (!request) return null + const { + invoiceRequired, + notes, + schoolName, + submitterNotes, + teacherName, + ticketType + } = request + const internalNotes = [] + const operationalNotes = [] + notes && notes.forEach(note => { + if (note.type === 'internal') internalNotes.push(note) + else operationalNotes.push(note) + }) + return ( + + +
Group Information
+ + {schoolName} + Teacher: {teacherName} + Ticket type: {TICKET_TYPES[ticketType]} + Invoice required: {invoiceRequired ? 'Yes' : 'No'} + + } + onUpdate={this._editSubmitterNotes} + value={submitterNotes} + /> + + + + + {getGroupSize(request)} total + + } + fields={GROUP_FIELDS} + inputStyle={{lineHeight: '0.8em', padding: '0px', width: '50px'}} + onChange={setRequestGroupSize} + request={request} + valueFirst + /> + + + + + + Payment information + + } + fields={PAYMENT_FIELDS} + inputStyle={{lineHeight: '0.8em', padding: '0px', width: '100px'}} + onChange={setRequestPaymentInfo} + request={request} + /> + + +
+
+ ) + } +} + +const mapStateToProps = (state, ownProps) => { + const {activeId, requests} = state.callTaker.fieldTrip + const request = requests.data.find(req => req.id === activeId) + return { + currentQuery: state.otp.currentQuery, + datastoreUrl: state.otp.config.datastoreUrl, + dateFormat: getDateFormat(state.otp.config), + request + } +} + +const mapDispatchToProps = { + addFieldTripNote: fieldTripActions.addFieldTripNote, + deleteFieldTripNote: fieldTripActions.deleteFieldTripNote, + editSubmitterNotes: fieldTripActions.editSubmitterNotes, + fetchQueries: fieldTripActions.fetchQueries, + setActiveFieldTrip: fieldTripActions.setActiveFieldTrip, + setRequestGroupSize: fieldTripActions.setRequestGroupSize, + setRequestPaymentInfo: fieldTripActions.setRequestPaymentInfo, + setRequestStatus: fieldTripActions.setRequestStatus, + toggleFieldTrips: fieldTripActions.toggleFieldTrips +} + +export default connect(mapStateToProps, mapDispatchToProps)(FieldTripDetails) diff --git a/lib/components/admin/field-trip-list.js b/lib/components/admin/field-trip-list.js new file mode 100644 index 000000000..84152aa2d --- /dev/null +++ b/lib/components/admin/field-trip-list.js @@ -0,0 +1,206 @@ +import React, { Component } from 'react' +import { Badge } from 'react-bootstrap' +import { connect } from 'react-redux' + +import * as fieldTripActions from '../../actions/field-trip' +import DraggableWindow from './draggable-window' +import Icon from '../narrative/icon' +import Loading from '../narrative/loading' +import {FieldTripRecordButton, WindowHeader} from './styled' +import {getVisibleRequests, TABS} from '../../util/call-taker' +import {FETCH_STATUS} from '../../util/constants' + +/** + * Displays a searchable list of field trip requests in a draggable window. + */ +class FieldTripList extends Component { + _onClickFieldTrip = (request) => { + const {callTaker, fetchFieldTripDetails, setActiveFieldTrip} = this.props + if (request.id === callTaker.fieldTrip.activeId) { + this._onCloseActiveFieldTrip() + } else { + setActiveFieldTrip(request.id) + fetchFieldTripDetails(request.id) + } + } + + _onClickRefresh = () => this.props.fetchFieldTrips() + + _onCloseActiveFieldTrip = () => { + this.props.setActiveFieldTrip(null) + } + + /** + * Change search input selectively. This is to prevent excessive rendering + * each time the search input changes (on TriMet's production instance there + * are thousands of field trip requests). + */ + _handleSearchKeyUp = e => { + const {callTaker, setFieldTripFilter} = this.props + const {search} = callTaker.fieldTrip.filter + const newSearch = e.target.value + // Update filter if Enter is pressed or search value is entirely cleared. + const newSearchEntered = e.keyCode === 13 && newSearch !== search + const searchCleared = search && !newSearch + if (newSearchEntered || searchCleared) { + setFieldTripFilter({search: newSearch}) + } + } + + _onTabChange = e => { + this.props.setFieldTripFilter({tab: e.currentTarget.name}) + } + + render () { + const {callTaker, style, toggleFieldTrips, visibleRequests} = this.props + const {fieldTrip} = callTaker + const {activeId, filter} = fieldTrip + const {search} = filter + return ( + + + Field Trip Requests{' '} + + + + + + {TABS.map(tab => { + const active = tab.id === filter.tab + const style = { + backgroundColor: active ? 'navy' : undefined, + borderRadius: 5, + color: active ? 'white' : undefined, + padding: '2px 3px' + } + const requestCount = fieldTrip.requests.data.filter(tab.filter).length + return ( + + ) + })} + + } + onClickClose={toggleFieldTrips} + style={style} + > + {fieldTrip.requests.status === FETCH_STATUS.FETCHING + ? + : visibleRequests.length > 0 + ? visibleRequests.map((request, i) => ( + + )) + :
No field trips found.
+ } +
+ ) + } +} + +class FieldTripRequestRecord extends Component { + _onClick = () => { + const {onClick, request} = this.props + onClick(request) + } + + _getStatusIcon = (status) => status + ? + : + + render () { + const {active, request} = this.props + const style = { + backgroundColor: active ? 'lightgrey' : undefined, + borderBottom: '1px solid grey' + } + const { + endLocation, + id, + inboundTripStatus, + outboundTripStatus, + schoolName, + startLocation, + teacherName, + timeStamp + } = request + return ( +
  • + + + {schoolName} Trip (#{id}) + + + + {this._getStatusIcon(inboundTripStatus)} Inbound + + + {this._getStatusIcon(outboundTripStatus)} Outbound + + + + Submitted by {teacherName} on {timeStamp} + + + {startLocation} to {endLocation} + + +
  • + ) + } +} + +const mapStateToProps = (state, ownProps) => { + return { + callTaker: state.callTaker, + currentQuery: state.otp.currentQuery, + searches: state.otp.searches, + visibleRequests: getVisibleRequests(state) + } +} + +const mapDispatchToProps = { + fetchFieldTripDetails: fieldTripActions.fetchFieldTripDetails, + fetchFieldTrips: fieldTripActions.fetchFieldTrips, + setActiveFieldTrip: fieldTripActions.setActiveFieldTrip, + setFieldTripFilter: fieldTripActions.setFieldTripFilter, + toggleFieldTrips: fieldTripActions.toggleFieldTrips +} + +export default connect(mapStateToProps, mapDispatchToProps)(FieldTripList) diff --git a/lib/components/admin/field-trip-notes.js b/lib/components/admin/field-trip-notes.js new file mode 100644 index 000000000..a190f5f5d --- /dev/null +++ b/lib/components/admin/field-trip-notes.js @@ -0,0 +1,116 @@ +import React, { Component } from 'react' +import { Badge, Button as BsButton } from 'react-bootstrap' +import styled from 'styled-components' + +import Icon from '../narrative/icon' +import { + Button, + Full, + Header +} from './styled' + +const Quote = styled.p` + font-size: small; + margin-bottom: 5px; +` + +const Footer = styled.footer` + font-size: x-small; +` + +const Note = ({note, onClickDelete}) => ( +
    + onClickDelete(note)} + > + + + {note.note} +
    {note.userName} on {note.timeStamp}
    +
    +) + +const Feedback = ({feedback}) => ( +
    + {feedback.feedback} +
    {feedback.userName} on {feedback.timeStamp}
    +
    +) +/** + * Renders the various notes/feedback for a field trip request. + */ +export default class FieldTripNotes extends Component { + _getNotesCount = () => { + const {request} = this.props + const {feedback, notes} = request + let notesCount = 0 + if (notes && notes.length) notesCount += notes.length + if (feedback && feedback.length) notesCount += feedback.length + return notesCount + } + + _addInternalNote = () => this._addNote('internal') + + _addOperationalNote = () => this._addNote('operational') + + _addNote = (type) => { + const {addFieldTripNote, request} = this.props + const note = prompt(`Type ${type} note to be attached to this request:`) + if (note) addFieldTripNote(request, {note, type}) + } + + _deleteNote = (note) => { + const {deleteFieldTripNote, request} = this.props + if (confirm(`Are you sure you want to delete note "${note.note}"?`)) { + deleteFieldTripNote(request, note.id) + } + } + + render () { + const {request} = this.props + if (!request) return null + const { + feedback, + notes + } = request + const internalNotes = [] + const operationalNotes = [] + notes && notes.forEach(note => { + if (note.type === 'internal') internalNotes.push(note) + else operationalNotes.push(note) + }) + return ( + +
    + Notes/Feedback{' '} + {this._getNotesCount()} + + +
    +
    Internal agent notes
    + {internalNotes && internalNotes.length > 0 + ? internalNotes.map(n => + ) + : 'No internal notes submitted.' + } +
    Operational notes
    + {operationalNotes && operationalNotes.length > 0 + ? operationalNotes.map(n => + ) + : 'No operational notes submitted.' + } +
    User feedback
    + {feedback && feedback.length > 0 + ? feedback.map((f, i) => ) + : 'No feedback submitted.' + } +
    + ) + } +} diff --git a/lib/components/admin/field-trip-windows.js b/lib/components/admin/field-trip-windows.js new file mode 100644 index 000000000..ddb6ee7ca --- /dev/null +++ b/lib/components/admin/field-trip-windows.js @@ -0,0 +1,46 @@ +import React from 'react' +import { connect } from 'react-redux' + +import FieldTripDetails from './field-trip-details' +import FieldTripList from './field-trip-list' + +const WINDOW_WIDTH = 450 +const MARGIN = 15 + +/** + * Collects the various draggable windows for the Field Trip module. + */ +const FieldTripWindows = ({callTaker}) => { + const {fieldTrip} = callTaker + // Do not render details or list if visible is false. + if (!fieldTrip.visible) return null + return ( + <> + + + + ) +} + +const mapStateToProps = (state, ownProps) => { + return { + callTaker: state.callTaker + } +} + +export default connect(mapStateToProps)(FieldTripWindows) diff --git a/lib/components/admin/styled.js b/lib/components/admin/styled.js new file mode 100644 index 000000000..c5f53259d --- /dev/null +++ b/lib/components/admin/styled.js @@ -0,0 +1,134 @@ +import { Button as BsButton } from 'react-bootstrap' +import styled, {css} from 'styled-components' + +import DefaultCounter from './call-time-counter' + +// Call Taker Controls Components + +const RED = '#C35134' +const BLUE = '#1C4D89' +const GREEN = '#6B931B' +const PURPLE = '#8134D3' + +const circleButtonStyle = css` + border: none; + border-radius: 50%; + box-shadow: 2px 2px 4px #000000; + color: white; + position: absolute; + z-index: 999999; +` + +export const CallHistoryButton = styled.button` + ${circleButtonStyle} + background-color: ${GREEN}; + height: 40px; + margin-left: 69px; + top: 140px; + width: 40px; +` + +export const CallTimeCounter = styled(DefaultCounter)` + background-color: ${BLUE}; + border-radius: 20px; + box-shadow: 2px 2px 4px #000000; + color: white; + font-weight: 600; + margin-left: -8px; + position: absolute; + text-align: center; + top: 241px; + width: 80px; + z-index: 999999; +` + +export const ControlsContainer = styled.div` + position: relative; +` + +export const FieldTripsButton = styled.button` + ${circleButtonStyle} + background-color: ${PURPLE}; + height: 50px; + margin-left: 80px; + top: 190px; + width: 50px; +` + +export const ToggleCallButton = styled.button` + ${circleButtonStyle} + background-color: ${props => props.callInProgress ? RED : BLUE}; + height: 80px; + margin-left: -8px; + top: 154px; + width: 80px; +` + +// Field Trip Windows Components + +export const Bold = styled.strong`` + +export const Button = styled(BsButton)` + margin-left: 5px; +` + +export const Container = styled.div` + display: flex; + flex-flow: row wrap; +` + +export const Half = styled.div` + width: 50% +` + +// Make sure button extends to end of window. +export const FieldTripRecordButton = styled.button` + display: inline-block; + width: 100%; + width: -moz-available; + width: -webkit-fill-available; + width: fill-available; +` + +export const Full = styled.div` + width: 100% +` + +export const FullWithMargin = styled(Full)` + margin-top: 10px; +` + +export const Header = styled.h4` + margin-bottom: 5px; + width: 100%; +` + +export const InlineHeader = styled(Header)` + display: inline; +` + +export const textCss = css` + font-size: 0.9em; + margin-bottom: 0px; +` + +export const Para = styled.p` + ${textCss} +` + +export const Text = styled.span` + ${textCss} +` + +export const Val = styled.span` + :empty:before { + color: grey; + content: 'N/A'; + } +` + +export const WindowHeader = styled.h3` + font-size: 18px; + margin-bottom: 10px; + margin-top: 10px; +` diff --git a/lib/components/admin/trip-status.js b/lib/components/admin/trip-status.js new file mode 100644 index 000000000..741b12912 --- /dev/null +++ b/lib/components/admin/trip-status.js @@ -0,0 +1,109 @@ +import { getTimeFormat } from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import React, {Component} from 'react' +import { connect } from 'react-redux' + +import * as fieldTripActions from '../../actions/field-trip' +import * as formActions from '../../actions/form' +import Icon from '../narrative/icon' +import { + Bold, + Button, + Full, + Header, + Para +} from './styled' +import { getTripFromRequest } from '../../util/call-taker' + +class TripStatus extends Component { + _getTrip = () => getTripFromRequest(this.props.request, this.props.outbound) + + _formatTime = (time) => moment(time).format(this.props.timeFormat) + + _formatTripStatus = () => { + if (!this._getStatus()) { + return ( + + No itineraries planned! Click Plan to plan trip. + + ) + } + const trip = this._getTrip() + if (!trip) return Error finding trip! + return ( + + {trip.groupItineraries.length} group itineraries, planned by{' '} + {trip.createdBy} at {trip.timeStamp} + + ) + } + + _getStatus = () => { + const {outbound, request} = this.props + return outbound ? request.outboundTripStatus : request.inboundTripStatus + } + + _getStatusIcon = () => this._getStatus() + ? + : + + _onPlanTrip = () => this.props.planTrip(this.props.request, this.props.outbound) + + _onSaveTrip = () => this.props.saveRequestTrip(this.props.request, this.props.outbound) + + render () { + const {outbound, request} = this.props + const { + arriveDestinationTime, + arriveSchoolTime, + endLocation, + leaveDestinationTime, + startLocation + } = request + if (!request) { + console.warn('Could not find field trip request') + return null + } + const start = outbound ? startLocation : endLocation + const end = outbound ? endLocation : startLocation + return ( + +
    + {this._getStatusIcon()} + {outbound ? 'Outbound' : 'Inbound'} trip + + +
    + From {start} to {end} + {outbound + ? + Arriving at {this._formatTime(arriveDestinationTime)} + + : <> + + Leave at {this._formatTime(leaveDestinationTime)},{' '} + due back at {this._formatTime(arriveSchoolTime)} + + + } + {this._formatTripStatus()} +
    + ) + } +} + +const mapStateToProps = (state, ownProps) => { + return { + callTaker: state.callTaker, + currentQuery: state.otp.currentQuery, + timeFormat: getTimeFormat(state.otp.config) + } +} + +const mapDispatchToProps = { + planTrip: fieldTripActions.planTrip, + saveRequestTrip: fieldTripActions.saveRequestTrip, + setQueryParam: formActions.setQueryParam +} + +export default connect(mapStateToProps, mapDispatchToProps)(TripStatus) diff --git a/lib/components/admin/updatable.js b/lib/components/admin/updatable.js new file mode 100644 index 000000000..16bd37246 --- /dev/null +++ b/lib/components/admin/updatable.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' +import { Button } from 'react-bootstrap' + +import { Val } from './styled' + +export default class Updatable extends Component { + _onClick = () => { + const {fieldName, onUpdate, value} = this.props + const newValue = window.prompt( + `Please input new value for ${fieldName}:`, + value + ) + if (newValue !== null) onUpdate(newValue) + } + + render () { + const {fieldName, label, value} = this.props + return ( + <> + {label || fieldName}:{' '} + {value} + + + ) + } +} diff --git a/lib/components/app/app-frame.js b/lib/components/app/app-frame.js new file mode 100644 index 000000000..30aa4776f --- /dev/null +++ b/lib/components/app/app-frame.js @@ -0,0 +1,41 @@ +import React from 'react' +import { Col, Row } from 'react-bootstrap' + +import DesktopNav from './desktop-nav' +import NotFound from './not-found' + +/** + * This component defines the general application frame, to which + * content and an optional sub-navigation component can be inserted. + */ +const AppFrame = ({ children, SubNav }) => ( +
    + {/* TODO: Do mobile view. */} + + {SubNav && } +
    + + + {children} + + +
    +
    +) + +/** + * Creates a simple wrapper component consisting of an AppFrame that surrounds + * the provided component. (Displays "Content not found" if none provided.) + */ +export function frame (Component) { + return () => ( + + {Component + ? + : + } + + ) +} + +export default AppFrame diff --git a/lib/components/app/app-menu.js b/lib/components/app/app-menu.js index 6bf4d9374..703c0752d 100644 --- a/lib/components/app/app-menu.js +++ b/lib/components/app/app-menu.js @@ -1,7 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import qs from 'qs' import { connect } from 'react-redux' import { DropdownButton, MenuItem } from 'react-bootstrap' +import { withRouter } from 'react-router' import Icon from '../narrative/icon' @@ -19,11 +21,21 @@ class AppMenu extends Component { } _startOver = () => { - const { reactRouterConfig } = this.props + const { location, reactRouterConfig } = this.props + const { search } = location let startOverUrl = '/' if (reactRouterConfig && reactRouterConfig.basename) { startOverUrl += reactRouterConfig.basename } + // If search contains sessionId, preserve this so that the current session + // is not lost when the page reloads. + if (search) { + const params = qs.parse(search, { ignoreQueryPrefix: true }) + const { sessionId } = params + if (sessionId) { + startOverUrl += `?${qs.stringify({sessionId})}` + } + } window.location.href = startOverUrl } @@ -62,4 +74,4 @@ const mapDispatchToProps = { setMainPanelContent } -export default connect(mapStateToProps, mapDispatchToProps)(AppMenu) +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(AppMenu)) diff --git a/lib/components/app/batch-routing-panel.js b/lib/components/app/batch-routing-panel.js index 93d2e1975..0cc9a36a8 100644 --- a/lib/components/app/batch-routing-panel.js +++ b/lib/components/app/batch-routing-panel.js @@ -1,81 +1,15 @@ -import coreUtils from '@opentripplanner/core-utils' import React, { Component } from 'react' import { connect } from 'react-redux' import styled from 'styled-components' import * as apiActions from '../../actions/api' import * as formActions from '../../actions/form' -import BatchSettingsPanel from '../form/batch-settings-panel' +import BatchSettings from '../form/batch-settings' import LocationField from '../form/connected-location-field' -import DateTimeModal from '../form/date-time-modal' -import ModeButtons, {MODE_OPTIONS, StyledModeButton} from '../form/mode-buttons' -// import UserSettings from '../form/user-settings' -import Icon from '../narrative/icon' import NarrativeItineraries from '../narrative/narrative-itineraries' -import { - BatchSettingsPanelContainer, - DateTimeModalContainer, - Dot, - MainSettingsRow, - PlanTripButton, - SettingsPreview, - StyledDateTimePreview -} from './styled' -import { hasValidLocation, getActiveSearch, getShowUserSettings } from '../../util/state' +import { getActiveSearch, getShowUserSettings } from '../../util/state' import ViewerContainer from '../viewers/viewer-container' -/** - * Simple utility to check whether a list of mode strings contains the provided - * mode. This handles exact match and prefix/suffix matches (i.e., checking - * 'BICYCLE' will return true if 'BICYCLE' or 'BICYCLE_RENT' is in the list). - * - * FIXME: This might need to be modified to be a bit looser in how it handles - * the 'contains' check. E.g., we might not want to remove WALK,TRANSIT if walk - * is turned off, but we DO want to remove it if TRANSIT is turned off. - */ -function listHasMode (modes, mode) { - return modes.some(m => mode.indexOf(m) !== -1) -} - -function combinationHasAnyOfModes (combination, modes) { - return combination.mode.split(',').some(m => listHasMode(modes, m)) -} - -// List of possible modes that can be selected via mode buttons. -const POSSIBLE_MODES = MODE_OPTIONS.map(b => b.mode) - -const ModeButtonsFullWidthContainer = styled.div` - display: flex; - justify-content: space-between; - margin-bottom: 5px; -` - -// Define Mode Button styled components here to avoid circular imports. I.e., we -// cannot define them in styled.js (because mode-buttons.js imports buttonCss -// and then we would need to import ModeButtons/StyledModeButton from that file -// in turn). -const StyledModeButtonsFullWidth = styled(ModeButtons)` - &:last-child { - margin-right: 0px; - } -` - -const ModeButtonsContainerCompressed = styled.div` - display: contents; -` - -const ModeButtonsCompressed = styled(ModeButtons)` - ${StyledModeButton} { - border-radius: 0px; - } - &:first-child { - border-radius: 5px 0px 0px 5px; - } - &:last-child { - margin-right: 5px; - border-radius: 0px 5px 5px 0px; - } -` // Style for setting the top of the narrative itineraries based on the width of the window. // If the window width is less than 1200px (Bootstrap's "large" size), the // mode buttons will be shown on their own row, meaning that the @@ -97,56 +31,8 @@ const NarrativeContainer = styled.div` * Main panel for the batch/trip comparison form. */ class BatchRoutingPanel extends Component { - state = { - expanded: null, - selectedModes: POSSIBLE_MODES - } - - _onClickMode = (mode) => { - const {possibleCombinations, setQueryParam} = this.props - const {selectedModes} = this.state - const index = selectedModes.indexOf(mode) - const enableMode = index === -1 - const newModes = [...selectedModes] - if (enableMode) newModes.push(mode) - else newModes.splice(index, 1) - // Update selected modes for mode buttons. - this.setState({selectedModes: newModes}) - // Update the available mode combinations based on the new modes selection. - const disabledModes = POSSIBLE_MODES.filter(m => !newModes.includes(m)) - // Do not include combination if any of its modes are found in disabled - // modes list. - const newCombinations = possibleCombinations - .filter(c => !combinationHasAnyOfModes(c, disabledModes)) - setQueryParam({combinations: newCombinations}) - } - - _planTrip = () => { - const {currentQuery, routingQuery} = this.props - // Check for any validation issues in query. - const issues = [] - if (!hasValidLocation(currentQuery, 'from')) issues.push('from') - if (!hasValidLocation(currentQuery, 'to')) issues.push('to') - if (issues.length > 0) { - // TODO: replace with less obtrusive validation. - window.alert(`Please define the following fields to plan a trip: ${issues.join(', ')}`) - return - } - // Close any expanded panels. - this.setState({expanded: null}) - // Plan trip. - routingQuery() - } - - _updateExpanded = (type) => ({expanded: this.state.expanded === type ? null : type}) - - _toggleDateTime = () => this.setState(this._updateExpanded('DATE_TIME')) - - _toggleSettings = () => this.setState(this._updateExpanded('SETTINGS')) - render () { - const {config, currentQuery, mobile} = this.props - const {expanded, selectedModes} = this.state + const {mobile} = this.props const actionText = mobile ? 'tap' : 'click' return ( @@ -160,58 +46,12 @@ class BatchRoutingPanel extends Component { locationType='to' showClearButton={!mobile} /> - - - - - - {coreUtils.query.isNotDefaultQuery(currentQuery, config) && - - } - - - - - - - - - - - {expanded === 'DATE_TIME' && - - - - } - {expanded === 'SETTINGS' && - - - - } + {/* FIXME: Add back user settings (home, work, etc.) once connected to the middleware persistence. !activeSearch && showUserSettings && */} - {/* TODO: Implement mobile view */} * { - padding: 3px 5px 3px 0px; - } - > :last-child { - padding-right: 0px; - } - ${TripFormClasses.ModeButton.Button} { - padding: 6px 12px; - } - svg, - img { - margin-left: 0px; - } - } - ${TripFormClasses.SubmodeSelector.InlineRow} { - margin: -3px 0px; - } - - ${TripFormClasses.SubmodeSelector} { - ${modeButtonButtonCss} - } -` - -const departureOptions = [ - { - // Default option. - value: 'NOW', - children: 'Now' - }, - { - value: 'DEPART', - children: 'Depart at' - }, - { - value: 'ARRIVE', - children: 'Arrive by' - } -] - -const modeOptions = [ - { - // Default option. - value: 'TRANSIT', - children: 'Transit' - }, - { - value: 'WALK', - children: 'Walk only' - }, - { - value: 'BICYCLE', - children: 'Bike only' - }, - { - value: 'BICYCLE,TRANSIT', - children: 'Bike to transit' - } -] - -const metersToMiles = meters => Math.round(meters * 0.000621371 * 100) / 100 -const milesToMeters = miles => miles / 0.000621371 - /** * This is the main panel/sidebar for the Call Taker/Field Trip module. It * currently also serves as the main panel for the FDOT RMCE trip comparison view @@ -114,18 +44,10 @@ class CallTakerPanel extends Component { window.alert(`Please define the following fields to plan a trip: ${issues.join(', ')}`) return } + if (this.state.expandAdvanced) this.setState({expandAdvanced: false}) routingQuery() } - modeToOptionValue = mode => { - const isTransit = hasTransit(mode) - const isBike = hasBike(mode) - if (isTransit && isBike) return 'BICYCLE,TRANSIT' - else if (isTransit) return 'TRANSIT' - // Currently handles bicycle - else return mode - } - _addPlace = (result, index) => { const intermediatePlaces = [...this.props.currentQuery.intermediatePlaces] || [] if (result && index !== undefined) { @@ -145,25 +67,6 @@ class CallTakerPanel extends Component { this.props.setQueryParam({intermediatePlaces}) } - _setMode = evt => { - const {value: mode} = evt.target - const transitIsSelected = mode.indexOf('TRANSIT') !== -1 - if (transitIsSelected) { - // Collect transit modes and selected access mode. - const accessMode = mode === 'TRANSIT' ? 'WALK' : 'BICYCLE' - // If no transit is selected, selected all available. Otherwise, default - // to state. - const transitModes = this.state.transitModes.length > 0 - ? this.state.transitModes - : this.props.modes.transitModes.map(m => m.mode) - const newModes = [accessMode, ...transitModes].join(',') - this.setState({transitModes}) - this.props.setQueryParam({ mode: newModes }) - } else { - this.props.setQueryParam({ mode }) - } - } - _onHideAdvancedClick = () => { const expandAdvanced = !this.state.expandAdvanced // FIXME move logic to action @@ -177,8 +80,6 @@ class CallTakerPanel extends Component { * keyboard-only operation of the trip planning form. Note: it generally should * not be passed to buttons or other elements that natively rely on the Enter * key. - * - * FIXME: Should we use a proper submit button instead? */ _handleFormKeyDown = (evt) => { switch (evt.keyCode) { @@ -193,22 +94,25 @@ class CallTakerPanel extends Component { } } + _updateGroupSize = (evt) => this.props.setGroupSize(+evt.target.value) + render () { const { activeSearch, currentQuery, + groupSize, mainPanelContent, + maxGroupSize, mobile, modes, routes, setQueryParam, - showUserSettings + showUserSettings, + timeFormat } = this.props // FIXME: Remove showPlanTripButton const showPlanTripButton = mainPanelContent === 'EDIT_DATETIME' || mainPanelContent === 'EDIT_SETTINGS' - // const mostRecentQuery = activeSearch ? activeSearch.query : null - // const planDisabled = isEqual(currentQuery, mostRecentQuery) const { departArrive, date, @@ -227,11 +131,8 @@ class CallTakerPanel extends Component { right: '0px', left: '0px', padding: '0px 8px 5px', - display: expandAdvanced ? 'none' : undefined + display: expandAdvanced ? undefined : 'none' } - // Only permit adding intermediate place if from/to is defined. - const maxPlacesDefined = intermediatePlaces.length >= 3 - const addIntermediateDisabled = !from || !to || maxPlacesDefined return ( {/* FIXME: should this be a styled component */} @@ -273,67 +174,73 @@ class CallTakerPanel extends Component { showClearButton={!mobile} />
    } />
    - +
    - + selectedTransitModes={this.state.transitModes} + updateTransitModes={transitModes => this.setState({transitModes})} />
    + {groupSize !== null && maxGroupSize && + + Group size:{' '} + + + }
    - + setQueryParam={setQueryParam} />
    @@ -361,307 +268,29 @@ class CallTakerPanel extends Component { } } -class CallTakerAdvancedOptions extends Component { - constructor (props) { - super(props) - this.state = { - expandAdvanced: props.expandAdvanced, - routeOptions: [], - transitModes: props.modes.transitModes.map(m => m.mode) - } - } - - static contextType = ComponentContext - - componentDidMount () { - // Fetch routes for banned/preferred routes selectors. - this.props.findRoutes() - } - - componentWillReceiveProps (nextProps) { - const {routes} = nextProps - // Once routes are available, map them to the route options format. - if (routes && !this.props.routes) { - const routeOptions = Object.values(routes).map(this.routeToOption) - this.setState({routeOptions}) - } - } - - _setBannedRoutes = options => { - const bannedRoutes = options ? options.map(o => o.value).join(',') : '' - this.props.setQueryParam({ bannedRoutes }) - } - - _setPreferredRoutes = options => { - const preferredRoutes = options ? options.map(o => (o.value)).join(',') : '' - this.props.setQueryParam({ preferredRoutes }) - } - - _isBannedRouteOptionDisabled = option => { - // Disable routes that are preferred already. - const preferredRoutes = this.getRouteList('preferredRoutes') - return preferredRoutes && preferredRoutes.find(o => o.value === option.value) - } - - _isPreferredRouteOptionDisabled = option => { - // Disable routes that are banned already. - const bannedRoutes = this.getRouteList('bannedRoutes') - return bannedRoutes && bannedRoutes.find(o => o.value === option.value) - } - - getDistanceStep = distanceInMeters => { - // Determine step for max walk/bike based on current value. Increment by a - // quarter mile if dealing with small values, whatever number will round off - // the number if it is not an integer, or default to one mile. - return metersToMiles(distanceInMeters) <= 2 - ? '.25' - : metersToMiles(distanceInMeters) % 1 !== 0 - ? `${metersToMiles(distanceInMeters) % 1}` - : '1' - } - - _onSubModeChange = changedMode => { - // Get previous transit modes from state and all modes from query. - const transitModes = [...this.state.transitModes] - const allModes = this.props.currentQuery.mode.split(',') - const index = transitModes.indexOf(changedMode) - if (index === -1) { - // If submode was not selected, add it. - transitModes.push(changedMode) - allModes.push(changedMode) - } else { - // Otherwise, remove it. - transitModes.splice(index, 1) - const i = allModes.indexOf(changedMode) - allModes.splice(i, 1) - } - // Update transit modes in state. - this.setState({transitModes}) - // Update all modes in query (set to walk if all transit modes inactive). - this.props.setQueryParam({ mode: allModes.join(',') || 'WALK' }) - } - - _setMaxWalkDistance = evt => { - this.props.setQueryParam({ maxWalkDistance: milesToMeters(evt.target.value) }) - } - - /** - * Get list of routes for specified key (either 'bannedRoutes' or - * 'preferredRoutes'). - */ - getRouteList = key => { - const routesParam = this.props.currentQuery[key] - const idList = routesParam ? routesParam.split(',') : [] - if (this.state.routeOptions) { - return this.state.routeOptions.filter(o => idList.indexOf(o.value) !== -1) - } else { - // If route list is not available, default labels to route IDs. - return idList.map(id => ({value: id, label: id})) - } - } - - routeToOption = route => { - if (!route) return null - const {id, longName, shortName} = route - // For some reason the OTP API expects route IDs in this double - // underscore format - // FIXME: This replace is flimsy! What if there are more colons? - const value = id.replace(':', '__') - const label = shortName - ? `${shortName}${longName ? ` - ${longName}` : ''}` - : longName - return {value, label} - } - - render () { - const { currentQuery, modes } = this.props - const { ModeIcon } = this.context - - const {maxBikeDistance, maxWalkDistance, mode} = currentQuery - const bannedRoutes = this.getRouteList('bannedRoutes') - const preferredRoutes = this.getRouteList('preferredRoutes') - const transitModes = modes.transitModes.map(modeObj => { - const modeStr = modeObj.mode || modeObj - return { - id: modeStr, - selected: this.state.transitModes.indexOf(modeStr) !== -1, - text: ( - - - - ), - title: modeObj.label - } - }) - // FIXME: Set units via config. - const unitsString = '(mi.)' - return ( -
    -
    - - {hasBike(mode) - ? - : null - } - -
    - -
    - ) - } -} - -const TIME_FORMATS = [ - 'HH:mm:ss', - 'h:mm:ss a', - 'h:mm:ssa', - 'h:mm a', - 'h:mma', - 'h:mm', - 'HHmm', - 'hmm', - 'ha', - 'h', - 'HH:mm' -].map(format => `YYYY-MM-DDT${format}`) - -class DateTimeOptions extends Component { - _setDepartArrive = evt => { - const {value: departArrive} = evt.target - if (departArrive === 'NOW') { - this.props.setQueryParam({ - departArrive, - date: moment().format(OTP_API_DATE_FORMAT), - time: moment().format(OTP_API_TIME_FORMAT) - }) - } else { - this.props.setQueryParam({ departArrive }) - } - } - - handleDateChange = evt => { - this.props.setQueryParam({ date: evt.target.value }) - } - - handleTimeChange = evt => { - const timeInput = evt.target.value - console.log(timeInput) - const date = moment().startOf('day').format('YYYY-MM-DD') - const time = moment(date + 'T' + timeInput, TIME_FORMATS) - this.props.setQueryParam({ time: time.format(OTP_API_TIME_FORMAT) }) - } - - render () { - const {date, departArrive, time} = this.props - const leaveNow = departArrive === 'NOW' && !date && !time - const dateTime = moment(`${date} ${time}`) - const cleanDate = dateTime.format('YYYY-MM-DD') - const cleanTime = dateTime.format('HH:mm') - return ( - <> - - {leaveNow - ? null - : - {cleanTime} - - - } - {leaveNow - ? null - : - } - - ) - } -} - // connect to the redux store const mapStateToProps = (state, ownProps) => { + const {activeId, requests} = state.callTaker.fieldTrip + const request = requests.data.find(req => req.id === activeId) const showUserSettings = getShowUserSettings(state.otp) return { activeSearch: getActiveSearch(state.otp), currentQuery: state.otp.currentQuery, expandAdvanced: state.otp.user.expandAdvanced, + groupSize: state.callTaker.fieldTrip.groupSize, mainPanelContent: state.otp.ui.mainPanelContent, + maxGroupSize: getGroupSize(request), modes: state.otp.config.modes, routes: state.otp.transitIndex.routes, - showUserSettings + showUserSettings, + timeFormat: getTimeFormat(state.otp.config) } } const mapDispatchToProps = { findRoutes: apiActions.findRoutes, routingQuery: apiActions.routingQuery, + setGroupSize: fieldTripActions.setGroupSize, setQueryParam: formActions.setQueryParam } diff --git a/lib/components/app/not-found.js b/lib/components/app/not-found.js new file mode 100644 index 000000000..4733b08b7 --- /dev/null +++ b/lib/components/app/not-found.js @@ -0,0 +1,19 @@ +import React from 'react' +import { Alert, Glyphicon } from 'react-bootstrap' +import styled from 'styled-components' + +const StyledAlert = styled(Alert)` + margin-top: 25px; +` + +/** + * Displays a not-found alert if some content is not found. + */ +const NotFound = () => ( + +

    Content not found

    +

    The content you requested is not available.

    +
    +) + +export default NotFound diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js index 7b2b9b27d..8dfd7f934 100644 --- a/lib/components/app/responsive-webapp.js +++ b/lib/components/app/responsive-webapp.js @@ -4,11 +4,12 @@ import { createHashHistory } from 'history' import isEqual from 'lodash.isequal' import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' +import qs from 'qs' import React, { Component } from 'react' +import { Col, Grid, Row } from 'react-bootstrap' import { connect } from 'react-redux' -import { Redirect, Route, Switch, withRouter } from 'react-router' +import { Route, Switch, withRouter } from 'react-router' -import PrintLayout from './print-layout' import * as authActions from '../../actions/auth' import * as callTakerActions from '../../actions/call-taker' import * as configActions from '../../actions/config' @@ -16,6 +17,12 @@ import * as formActions from '../../actions/form' import * as locationActions from '../../actions/location' import * as mapActions from '../../actions/map' import * as uiActions from '../../actions/ui' +import { frame } from '../app/app-frame' +import DesktopNav from './desktop-nav' +import { RedirectWithQuery } from '../form/connected-links' +import Map from '../map/map' +import MobileMain from '../mobile/main' +import PrintLayout from './print-layout' import { getAuth0Config } from '../../util/auth' import { ACCOUNT_PATH, @@ -23,6 +30,11 @@ import { AUTH0_SCOPE, ACCOUNT_SETTINGS_PATH, CREATE_ACCOUNT_PATH, + CREATE_ACCOUNT_PLACES_PATH, + CREATE_ACCOUNT_VERIFY_PATH, + PLACES_PATH, + TERMS_OF_SERVICE_PATH, + TERMS_OF_STORAGE_PATH, TRIPS_PATH, URL_ROOT } from '../../util/constants' @@ -30,6 +42,7 @@ import { ComponentContext } from '../../util/contexts' import { getActiveItinerary, getTitle } from '../../util/state' import AfterSignInScreen from '../user/after-signin-screen' import BeforeSignInScreen from '../user/before-signin-screen' +import FavoritePlaceScreen from '../user/places/favorite-place-screen' import SavedTripList from '../user/monitored-trip/saved-trip-list' import SavedTripScreen from '../user/monitored-trip/saved-trip-screen' import UserAccountScreen from '../user/user-account-screen' @@ -39,12 +52,12 @@ const { isMobile } = coreUtils.ui class ResponsiveWebapp extends Component { static propTypes = { - desktopView: PropTypes.element, initZoomOnLocate: PropTypes.number, - mobileView: PropTypes.element, query: PropTypes.object } + static contextType = ComponentContext + /** Lifecycle methods **/ componentDidUpdate (prevProps) { @@ -108,18 +121,42 @@ class ResponsiveWebapp extends Component { } componentDidMount () { + const { + getCurrentPosition, + handleBackButtonPress, + initializeModules, + location, + matchContentToUrl, + parseUrlQueryString, + receivedPositionResponse, + title + } = this.props // Add on back button press behavior. - window.addEventListener('popstate', this.props.handleBackButtonPress) - const { location, title } = this.props + window.addEventListener('popstate', handleBackButtonPress) document.title = title + + // If a URL is detected without hash routing (e.g., http://localhost:9966?sessionId=test), + // window.location.search will have a value. In this case, we need to redirect to the URL root with the + // search reconstructed for use with the hash router. + // Exception: Do not redirect after auth0 login, which sets the URL in the form + // http://localhost:9966/?code=xxxxxxx&state=yyyyyyyyy that we want to preserve. + const search = window.location.search + if (search) { + const searchParams = qs.parse(search, { ignoreQueryPrefix: true }) + if (!(searchParams.code && searchParams.state)) { + window.location.href = `${URL_ROOT}/#/${search}` + return + } + } + if (isMobile()) { // If on mobile browser, check position on load - this.props.getCurrentPosition() + getCurrentPosition() // Also, watch for changes in position on mobile navigator.geolocation.watchPosition( // On success - position => { this.props.receivedPositionResponse({ position }) }, + position => { receivedPositionResponse({ position }) }, // On error error => { console.log('error in watchPosition', error) }, // Options @@ -129,14 +166,14 @@ class ResponsiveWebapp extends Component { // Handle routing to a specific part of the app (e.g. stop viewer) on page // load. (This happens prior to routing request in case special routerId is // set from URL.) - this.props.matchContentToUrl(this.props.location) + matchContentToUrl(location) if (location && location.search) { // Set search params and plan trip if routing enabled and a query exists // in the URL. - this.props.parseUrlQueryString() + parseUrlQueryString() } // Initialize call taker/field trip modules (check for valid auth session). - this.props.initializeModules() + initializeModules() } componentWillUnmount () { @@ -144,9 +181,45 @@ class ResponsiveWebapp extends Component { window.removeEventListener('popstate', this.props.handleBackButtonPress) } + renderDesktopView = () => { + const { MainControls, MainPanel, MapWindows } = this.context + return ( +
    + + + + + {/* + Note: the main tag provides a way for users of screen readers + to skip to the primary page content. + TODO: Find a better place. + */} +
    + {} +
    + + {MainControls && } + + {MapWindows && } + + +
    +
    +
    + ) + } + + renderMobileView = () => { + return ( + //
    Needed for accessibility checks. TODO: Find a better place. +
    + +
    + ) + } + render () { - const { desktopView, mobileView } = this.props - return isMobile() ? mobileView : desktopView + return isMobile() ? this.renderMobileView() : this.renderDesktopView() } } @@ -158,11 +231,11 @@ const mapStateToProps = (state, ownProps) => { activeItinerary: getActiveItinerary(state.otp), activeSearchId: state.otp.activeSearchId, currentPosition: state.otp.location.currentPosition, - query: state.otp.currentQuery, - searches: state.otp.searches, - mobileScreen: state.otp.ui.mobileScreen, initZoomOnLocate: state.otp.config.map && state.otp.config.map.initZoomOnLocate, + mobileScreen: state.otp.ui.mobileScreen, modeGroups: state.otp.config.modeGroups, + query: state.otp.currentQuery, + searches: state.otp.searches, title } } @@ -231,17 +304,32 @@ class RouterWrapperWithAuth0 extends Component { ]} render={() => } /> + - + + + + + + props.expanded ? activeCss : null} -` -export const SettingsPreview = styled(Button)` - line-height: 22px; - margin-right: 5px; - padding: 10px 0px; - position: relative; - ${props => props.expanded ? activeCss : null} -` - -export const Dot = styled.div` - position: absolute; - top: -3px; - right: -3px; - width: 10px; - height: 10px; - border-radius: 5px; - background-color: #f00; -` - -export const PlanTripButton = styled(Button)` - background-color: #F5F5A7; - margin-left: auto; - padding: 5px; - &:active { - ${activeCss} - background-color: #ededaf - } -` - -const expandableBoxCss = css` - background-color: rgb(239, 239, 239); - box-shadow: rgba(0, 0, 0, 0.32) 7px 12px 10px; - height: 245px; - border-radius: 5px 5px 5px 5px; - left: 10px; - position: absolute; - right: 10px; - z-index: 99999; -` - -export const DateTimeModalContainer = styled.div` - ${expandableBoxCss} - padding: 10px 20px; -` - -export const BatchSettingsPanelContainer = styled.div` - ${expandableBoxCss} - padding: 5px 10px; -` - -export const MainSettingsRow = styled.div` - align-items: top; - display: flex; - flex-direction: row; - justify-content: flex-start; - margin-bottom: 5px; -` diff --git a/lib/components/form/add-place-button.js b/lib/components/form/add-place-button.js new file mode 100644 index 000000000..178fbf9ad --- /dev/null +++ b/lib/components/form/add-place-button.js @@ -0,0 +1,25 @@ +import React from 'react' + +const AddPlaceButton = ({from, intermediatePlaces, onClick, to}) => { + // Only permit adding intermediate place if from/to is defined. + const maxPlacesDefined = intermediatePlaces.length >= 3 + const disabled = !from || !to || maxPlacesDefined + return ( + + ) +} + +export default AddPlaceButton diff --git a/lib/components/form/batch-settings-panel.js b/lib/components/form/batch-preferences.js similarity index 94% rename from lib/components/form/batch-settings-panel.js rename to lib/components/form/batch-preferences.js index 4e5dbb0e0..29718eac2 100644 --- a/lib/components/form/batch-settings-panel.js +++ b/lib/components/form/batch-preferences.js @@ -6,10 +6,10 @@ import { setQueryParam } from '../../actions/form' import { ComponentContext } from '../../util/contexts' import { getShowUserSettings } from '../../util/state' -import { StyledBatchSettingsPanel } from './styled' +import { StyledBatchPreferences } from './batch-styled' import UserTripSettings from './user-trip-settings' -class BatchSettingsPanel extends Component { +class BatchPreferences extends Component { static contextType = ComponentContext render () { @@ -26,7 +26,7 @@ class BatchSettingsPanel extends Component {
    {showUserSettings && } - mode.indexOf(m) !== -1) +} + +function combinationHasAnyOfModes (combination, modes) { + return combination.mode.split(',').some(m => listHasMode(modes, m)) +} + +// List of possible modes that can be selected via mode buttons. +const POSSIBLE_MODES = MODE_OPTIONS.map(b => b.mode) + +const ModeButtonsFullWidthContainer = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 5px; +` + +// Define Mode Button styled components here to avoid circular imports. I.e., we +// cannot define them in styled.js (because mode-buttons.js imports buttonCss +// and then we would need to import ModeButtons/StyledModeButton from that file +// in turn). +const ModeButtonsFullWidth = styled(ModeButtons)` + &:last-child { + margin-right: 0px; + } +` + +const ModeButtonsContainerCompressed = styled.div` + display: contents; +` + +const ModeButtonsCompressed = styled(ModeButtons)` + ${StyledModeButton} { + border-radius: 0px; + } + &:first-child { + border-radius: 5px 0px 0px 5px; + } + &:last-child { + margin-right: 5px; + border-radius: 0px 5px 5px 0px; + } +` + +/** + * Main panel for the batch/trip comparison form. + */ +class BatchSettings extends Component { + state = { + expanded: null, + selectedModes: POSSIBLE_MODES + } + + _onClickMode = (mode) => { + const {possibleCombinations, setQueryParam} = this.props + const {selectedModes} = this.state + const index = selectedModes.indexOf(mode) + const enableMode = index === -1 + const newModes = [...selectedModes] + if (enableMode) newModes.push(mode) + else newModes.splice(index, 1) + // Update selected modes for mode buttons. + this.setState({selectedModes: newModes}) + // Update the available mode combinations based on the new modes selection. + const disabledModes = POSSIBLE_MODES.filter(m => !newModes.includes(m)) + // Do not include combination if any of its modes are found in disabled + // modes list. + const newCombinations = possibleCombinations + .filter(c => !combinationHasAnyOfModes(c, disabledModes)) + setQueryParam({combinations: newCombinations}) + } + + _planTrip = () => { + const {currentQuery, routingQuery} = this.props + // Check for any validation issues in query. + const issues = [] + if (!hasValidLocation(currentQuery, 'from')) issues.push('from') + if (!hasValidLocation(currentQuery, 'to')) issues.push('to') + if (issues.length > 0) { + // TODO: replace with less obtrusive validation. + window.alert(`Please define the following fields to plan a trip: ${issues.join(', ')}`) + return + } + // Close any expanded panels. + this.setState({expanded: null}) + // Plan trip. + routingQuery() + } + + _updateExpanded = (type) => ({expanded: this.state.expanded === type ? null : type}) + + _toggleDateTime = () => this.setState(this._updateExpanded('DATE_TIME')) + + _toggleSettings = () => this.setState(this._updateExpanded('SETTINGS')) + + render () { + const {config, currentQuery} = this.props + const {expanded, selectedModes} = this.state + return ( + <> + + + + + + {coreUtils.query.isNotDefaultQuery(currentQuery, config) && + + } + + + + + + + + + + + {expanded === 'DATE_TIME' && + + + + } + {expanded === 'SETTINGS' && + + + + } + + ) + } +} + +// connect to the redux store +const mapStateToProps = (state, ownProps) => { + const showUserSettings = getShowUserSettings(state.otp) + return { + activeSearch: getActiveSearch(state.otp), + config: state.otp.config, + currentQuery: state.otp.currentQuery, + expandAdvanced: state.otp.user.expandAdvanced, + possibleCombinations: state.otp.config.modes.combinations, + showUserSettings + } +} + +const mapDispatchToProps = { + routingQuery: apiActions.routingQuery, + setQueryParam: formActions.setQueryParam +} + +export default connect(mapStateToProps, mapDispatchToProps)(BatchSettings) diff --git a/lib/components/form/batch-styled.js b/lib/components/form/batch-styled.js new file mode 100644 index 000000000..040ff96b5 --- /dev/null +++ b/lib/components/form/batch-styled.js @@ -0,0 +1,221 @@ +import * as TripFormClasses from '@opentripplanner/trip-form/lib/styled' +import { SettingsSelectorPanel } from '@opentripplanner/trip-form' +import styled, {css} from 'styled-components' + +import DateTimePreview from './date-time-preview' +import {commonInputCss, modeButtonButtonCss} from './styled' + +const SHADOW = 'inset 0px 0px 5px #c1c1c1' + +const activeCss = css` + background: #e5e5e5; + -webkit-box-shadow: ${SHADOW}; + -moz-box-shadow: ${SHADOW}; + box-shadow: ${SHADOW}; + outline: none; +` + +export const buttonCss = css` + height: 45px; + width: 45px; + margin: 0px; + border: 0px; + border-radius: 5px; + &:active { + ${activeCss} + } +` + +export const Button = styled.button` + ${buttonCss} +` + +export const StyledDateTimePreview = styled(DateTimePreview)` + ${buttonCss} + background-color: rgb(239, 239, 239); + cursor: pointer; + font-size: 12px; + margin-right: 5px; + padding: 7px 5px; + text-align: left; + white-space: nowrap; + width: 120px; + ${props => props.expanded ? activeCss : null} +` +export const SettingsPreview = styled(Button)` + line-height: 22px; + margin-right: 5px; + padding: 10px 0px; + position: relative; + ${props => props.expanded ? activeCss : null} +` + +export const PlanTripButton = styled(Button)` + background-color: #F5F5A7; + margin-left: auto; + padding: 5px; + &:active { + ${activeCss} + background-color: #ededaf + } +` + +const expandableBoxCss = css` + background-color: rgb(239, 239, 239); + box-shadow: rgba(0, 0, 0, 0.32) 7px 12px 10px; + border-radius: 5px 5px 5px 5px; + left: 10px; + position: absolute; + right: 10px; + z-index: 99999; +` + +export const DateTimeModalContainer = styled.div` + ${expandableBoxCss} + padding: 10px 20px; +` + +export const BatchPreferencesContainer = styled.div` + ${expandableBoxCss} + padding: 5px 10px; +` + +export const MainSettingsRow = styled.div` + align-items: top; + display: flex; + flex-direction: row; + justify-content: flex-start; + margin-bottom: 5px; +` + +// FIXME: This is identical to StyledSettingsSelectorPanel, with a +// couple of items set to display: none (SettingsHeader and ModeSelector) +export const StyledBatchPreferences = styled(SettingsSelectorPanel)` + ${modeButtonButtonCss} + + ${TripFormClasses.SettingLabel} { + color: #808080; + font-size: 14px; + font-weight: 100; + letter-spacing: 1px; + padding-top: 8px; + text-transform: uppercase; + } + ${TripFormClasses.SettingsHeader} { + display: none; + color: #333333; + font-size: 18px; + margin: 16px 0px; + } + ${TripFormClasses.SettingsSection} { + margin-bottom: 16px; + } + ${TripFormClasses.DropdownSelector} { + select { + ${commonInputCss} + -webkit-appearance: none; + border-radius: 3px; + font-size: 14px; + height: 34px; + line-height: 1.42857; + margin-bottom: 20px; + + &:focus { + border-color: #66afe9; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6); + outline: 0; + } + } + > div:last-child::after { + box-sizing: border-box; + color: #000; + content: "▼"; + font-size: 67%; + pointer-events: none; + position: absolute; + right: 8px; + top: 10px; + } + } + + ${TripFormClasses.ModeSelector} { + display: none; + font-weight: 300; + ${TripFormClasses.ModeButton.Button} { + box-shadow: none; + outline: none; + padding: 3px; + } + ${TripFormClasses.ModeButton.Title} { + font-size: 10px; + line-height: 12px; + padding: 4px 0px 0px; + + &.active { + font-weight: 600; + } + } + } + ${TripFormClasses.ModeSelector.MainRow} { + box-sizing: border-box; + font-size: 170%; + margin: 0px -10px 18px; + padding: 0px 5px; + ${TripFormClasses.ModeButton.Button} { + height: 54px; + width: 100%; + &.active { + font-weight: 600; + } + } + } + ${TripFormClasses.ModeSelector.SecondaryRow} { + margin: 0px -10px 10px; + ${TripFormClasses.ModeButton.Button} { + font-size: 130%; + font-weight: 800; + height: 46px; + > svg { + margin: 0 0.20em; + } + } + } + ${TripFormClasses.ModeSelector.TertiaryRow} { + font-size: 80%; + font-weight: 300; + margin: 0px -10px 10px; + text-align: center; + ${TripFormClasses.ModeButton.Button} { + height: 36px; + } + } + ${TripFormClasses.SubmodeSelector.Row} { + font-size: 12px; + > * { + padding: 3px 5px 3px 0px; + } + > :last-child { + padding-right: 0px; + } + ${TripFormClasses.ModeButton.Button} { + height: 35px; + } + svg, + img { + margin-left: 0px; + } + } + ${TripFormClasses.SubmodeSelector} { + ${TripFormClasses.SettingLabel} { + margin-bottom: 0; + } + } + ${TripFormClasses.SubmodeSelector.InlineRow} { + margin: -3px 0px; + svg, + img { + height: 18px; + max-width: 32px; + } + } +` diff --git a/lib/components/form/call-taker/advanced-options.js b/lib/components/form/call-taker/advanced-options.js new file mode 100644 index 000000000..00ae66942 --- /dev/null +++ b/lib/components/form/call-taker/advanced-options.js @@ -0,0 +1,224 @@ +import { hasBike } from '@opentripplanner/core-utils/lib/itinerary' +import {SubmodeSelector} from '@opentripplanner/trip-form' +import * as TripFormClasses from '@opentripplanner/trip-form/lib/styled' +import React, { Component } from 'react' +import Select from 'react-select' +import styled from 'styled-components' + +import { modeButtonButtonCss } from '../styled' +import { ComponentContext } from '../../../util/contexts' + +export const StyledSubmodeSelector = styled(SubmodeSelector)` + ${TripFormClasses.SubmodeSelector.Row} { + > * { + padding: 3px 5px 3px 0px; + } + > :last-child { + padding-right: 0px; + } + ${TripFormClasses.ModeButton.Button} { + padding: 6px 12px; + } + svg, + img { + margin-left: 0px; + } + } + ${TripFormClasses.SubmodeSelector.InlineRow} { + margin: -3px 0px; + } + + ${TripFormClasses.SubmodeSelector} { + ${modeButtonButtonCss} + } +` + +const metersToMiles = meters => Math.round(meters * 0.000621371 * 100) / 100 +const milesToMeters = miles => miles / 0.000621371 + +export default class AdvancedOptions extends Component { + constructor (props) { + super(props) + this.state = { + expandAdvanced: props.expandAdvanced, + routeOptions: [], + transitModes: props.modes.transitModes.map(m => m.mode) + } + } + + static contextType = ComponentContext + + componentDidMount () { + // Fetch routes for banned/preferred routes selectors. + this.props.findRoutes() + } + + componentDidUpdate (prevProps) { + const {routes} = this.props + // Once routes are available, map them to the route options format. + if (routes && !prevProps.routes) { + const routeOptions = Object.values(routes).map(this.routeToOption) + this.setState({routeOptions}) + } + } + + _setBannedRoutes = options => { + const bannedRoutes = options ? options.map(o => o.value).join(',') : '' + this.props.setQueryParam({ bannedRoutes }) + } + + _setPreferredRoutes = options => { + const preferredRoutes = options ? options.map(o => (o.value)).join(',') : '' + this.props.setQueryParam({ preferredRoutes }) + } + + _isBannedRouteOptionDisabled = option => { + // Disable routes that are preferred already. + const preferredRoutes = this.getRouteList('preferredRoutes') + return preferredRoutes && preferredRoutes.find(o => o.value === option.value) + } + + _isPreferredRouteOptionDisabled = option => { + // Disable routes that are banned already. + const bannedRoutes = this.getRouteList('bannedRoutes') + return bannedRoutes && bannedRoutes.find(o => o.value === option.value) + } + + getDistanceStep = distanceInMeters => { + // Determine step for max walk/bike based on current value. Increment by a + // quarter mile if dealing with small values, whatever number will round off + // the number if it is not an integer, or default to one mile. + return metersToMiles(distanceInMeters) <= 2 + ? '.25' + : metersToMiles(distanceInMeters) % 1 !== 0 + ? `${metersToMiles(distanceInMeters) % 1}` + : '1' + } + + _onSubModeChange = changedMode => { + // Get previous transit modes from state and all modes from query. + const transitModes = [...this.state.transitModes] + const allModes = this.props.currentQuery.mode.split(',') + const index = transitModes.indexOf(changedMode) + if (index === -1) { + // If submode was not selected, add it. + transitModes.push(changedMode) + allModes.push(changedMode) + } else { + // Otherwise, remove it. + transitModes.splice(index, 1) + const i = allModes.indexOf(changedMode) + allModes.splice(i, 1) + } + // Update transit modes in state. + this.setState({transitModes}) + // Update all modes in query (set to walk if all transit modes inactive). + this.props.setQueryParam({ mode: allModes.join(',') || 'WALK' }) + } + + _setMaxWalkDistance = evt => { + this.props.setQueryParam({ maxWalkDistance: milesToMeters(evt.target.value) }) + } + + /** + * Get list of routes for specified key (either 'bannedRoutes' or + * 'preferredRoutes'). + */ + getRouteList = key => { + const routesParam = this.props.currentQuery[key] + const idList = routesParam ? routesParam.split(',') : [] + if (this.state.routeOptions) { + return this.state.routeOptions.filter(o => idList.indexOf(o.value) !== -1) + } else { + // If route list is not available, default labels to route IDs. + return idList.map(id => ({value: id, label: id})) + } + } + + routeToOption = route => { + if (!route) return null + const {id, longName, shortName} = route + // For some reason the OTP API expects route IDs in this double + // underscore format + // FIXME: This replace is flimsy! What if there are more colons? + const value = id.replace(':', '__') + const label = shortName + ? `${shortName}${longName ? ` - ${longName}` : ''}` + : longName + return {value, label} + } + + render () { + const { currentQuery, modes, onKeyDown } = this.props + const { ModeIcon } = this.context + + const {maxBikeDistance, maxWalkDistance, mode} = currentQuery + const bannedRoutes = this.getRouteList('bannedRoutes') + const preferredRoutes = this.getRouteList('preferredRoutes') + const transitModes = modes.transitModes.map(modeObj => { + const modeStr = modeObj.mode || modeObj + return { + id: modeStr, + selected: this.state.transitModes.indexOf(modeStr) !== -1, + text: , + title: modeObj.label + } + }) + // FIXME: Set units via config. + const unitsString = '(mi.)' + return ( +
    +
    + + {hasBike(mode) + ? + : null + } + +
    + +
    + ) + } +} diff --git a/lib/components/form/call-taker/date-time-options.js b/lib/components/form/call-taker/date-time-options.js new file mode 100644 index 000000000..feb48b1e2 --- /dev/null +++ b/lib/components/form/call-taker/date-time-options.js @@ -0,0 +1,219 @@ +import { + OTP_API_DATE_FORMAT, + OTP_API_TIME_FORMAT +} from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import React, { Component } from 'react' +import { OverlayTrigger, Tooltip } from 'react-bootstrap' + +const departureOptions = [ + { + // Default option. + value: 'NOW', + children: 'Now' + }, + { + value: 'DEPART', + children: 'Depart at' + }, + { + value: 'ARRIVE', + children: 'Arrive by' + } +] + +/** + * Time formats passed to moment.js used to parse the user's time input. + */ +const SUPPORTED_TIME_FORMATS = [ + 'HH:mm:ss', + 'h:mm:ss a', + 'h:mm:ssa', + 'h:mm a', + 'h:mma', + 'h:mm', + 'HHmm', + 'hmm', + 'ha', + 'h', + 'HH:mm' +].map(format => `YYYY-MM-DDT${format}`) + +/** + * Convert input moment object to date/time query params in OTP API format. + * @param {[type]} [time=moment(] [description] + * @return {[type]} [description] + */ +function momentToQueryParams (time = moment()) { + return { + date: time.format(OTP_API_DATE_FORMAT), + time: time.format(OTP_API_TIME_FORMAT) + } +} + +/** + * Contains depart/arrive selector and time/date inputs for the admin-oriented + * Call Taker form. A few unique features/behaviors to note: + * - when "leave now" is selected the time/date will now stay up to date in the + * form and query params + * - the time input will interpret various time formats using moment.js so that + * users can quickly type a shorthand value (5p) and have that be parsed into + * the correct OTP format. + * - when a user changes the date or time, "leave now" (if selected) will + * automatically switch to "depart at". + + * @type {Object} + */ +export default class DateTimeOptions extends Component { + state = { + timeInput: '' + } + + componentDidMount () { + if (this.props.departArrive === 'NOW') { + this._startAutoRefresh() + } + } + + componentWillUnmount () { + this._stopAutoRefresh() + } + + componentDidUpdate (prevProps) { + const {departArrive} = this.props + // If departArrive has been changed to leave now, begin auto refresh. + if (departArrive !== prevProps.departArrive) { + if (departArrive === 'NOW') this._startAutoRefresh() + else this._stopAutoRefresh() + } + } + + _updateTimeInput = (time = moment()) => + // If auto-updating time input (for leave now), use short 24-hr format to + // avoid writing a value that is too long for the time input's width. + this.setState({timeInput: time.format('H:mm')}) + + _startAutoRefresh = () => { + const timer = window.setInterval(this._refreshDateTime, 1000) + this.setState({ timer }) + } + + _stopAutoRefresh = () => { + window.clearInterval(this.state.timer) + this.setState({timer: null}) + } + + _refreshDateTime = () => { + const now = moment() + this._updateTimeInput(now) + const dateTimeParams = momentToQueryParams(now) + // Update query param if the current time has changed (i.e., on minute ticks). + if (dateTimeParams.time !== this.props.time) { + this.props.setQueryParam(dateTimeParams) + } + } + + _setDepartArrive = evt => { + const {value: departArrive} = evt.target + if (departArrive === 'NOW') { + const now = moment() + // If setting to leave now, update date/time and start auto refresh to keep + // form input in sync. + this.props.setQueryParam({ + departArrive, + ...momentToQueryParams(now) + }) + if (!this.state.timer) { + this._startAutoRefresh() + } + } else { + // If set to depart at/arrive by, stop auto refresh. + this._stopAutoRefresh() + this.props.setQueryParam({ departArrive }) + } + } + + handleDateChange = evt => this.handleDateTimeChange({ date: evt.target.value }) + + /** + * Handler that should be used when date or time is manually updated. This + * will also update the departArrive value if need be. + */ + handleDateTimeChange = params => { + const {departArrive: prevDepartArrive} = this.props + // If previously set to leave now, change to depart at when time changes. + if (prevDepartArrive === 'NOW') params.departArrive = 'DEPART' + this.props.setQueryParam(params) + } + + /** + * Select input string when time input is focused by user (for quick changes). + */ + handleTimeFocus = evt => evt.target.select() + + handleTimeChange = evt => { + if (this.state.timer) this._stopAutoRefresh() + const timeInput = evt.target.value + const date = moment().startOf('day').format('YYYY-MM-DD') + const parsedTime = moment(date + 'T' + timeInput, SUPPORTED_TIME_FORMATS) + this.handleDateTimeChange({ time: parsedTime.format(OTP_API_TIME_FORMAT) }) + this.setState({timeInput}) + } + + render () { + const {date, departArrive, onKeyDown, time, timeFormat} = this.props + const {timeInput} = this.state + const dateTime = moment(`${date} ${time}`) + const cleanTime = dateTime.format(timeFormat) + return ( + <> + + + {cleanTime}} + placement='bottom' + trigger={['focus', 'hover']} + > + + + + + + ) + } +} diff --git a/lib/components/form/call-taker/mode-dropdown.js b/lib/components/form/call-taker/mode-dropdown.js new file mode 100644 index 000000000..1a43063b4 --- /dev/null +++ b/lib/components/form/call-taker/mode-dropdown.js @@ -0,0 +1,86 @@ +import { toSentenceCase } from '@opentripplanner/core-utils/lib/itinerary' +import React, { Component } from 'react' + +/** + * Dropdown selector for the Call Taker form that allows quick selection of the + * full set of exclusive modes and access modes + transit. This will also + * automatically apply any companies associated with the mode option to the + * query params (e.g., Uber for CAR_HAIL or one of the various bike/scooter + * rental companies). + */ +export default class ModeDropdown extends Component { + modeToOptionValue = mode => { + const {modes} = this.props + for (let i = 0; i < modes.exclusiveModes.length; i++) { + if (mode === modes.exclusiveModes[i]) return mode + } + for (let i = 0; i < modes.accessModes.length; i++) { + const accessMode = modes.accessModes[i].mode + const index = mode.indexOf(accessMode) + // Set value if mode matches and next character is not an underscore + // (otherwise we might incorrectly return BICYCLE instead of BICYCLE_RENT). + if (index !== -1 && mode[index + accessMode.length] !== '_') { + return `TRANSIT,${accessMode}` + } + } + // Default to transit + return 'TRANSIT' + } + + _getModeOptions = () => { + const {modes} = this.props + return [ + { mode: 'TRANSIT', value: 'TRANSIT', children: 'Transit' }, + ...modes.exclusiveModes.map(mode => + ({ mode, value: `${mode}`, children: `${toSentenceCase(mode)} only` }) + ), + ...modes.accessModes.map(m => + ({ ...m, value: `TRANSIT,${m.mode}`, children: m.label }) + ) + ] + } + + _onChange = evt => { + const {value: mode} = evt.target + const {updateTransitModes} = this.props + const selectedOption = this._getModeOptions().filter(o => o.value === mode) + const transitIsSelected = mode.indexOf('TRANSIT') !== -1 + if (transitIsSelected) { + // Collect transit modes and selected access mode. + const accessMode = mode === 'TRANSIT' ? 'WALK' : mode.replace('TRANSIT,', '') + // If no transit is selected, selected all available. Otherwise, default + // to state. + const transitModes = this.props.selectedTransitModes.length > 0 + ? this.props.selectedTransitModes + : this.props.modes.transitModes.map(m => m.mode) + const newModes = [...transitModes, accessMode] + if (accessMode.indexOf('CAR') !== -1) newModes.push('WALK') + updateTransitModes(transitModes) + const params = { mode: newModes.join(',') } + // Update companies if provided by selected option. + if (selectedOption && selectedOption.company) { + params.companies = selectedOption.company + } + this.props.onChange(params) + } else { + this.props.onChange({ mode }) + } + } + + render () { + const {mode, onKeyDown} = this.props + return ( + + ) + } +} diff --git a/lib/components/form/connect-location-field.js b/lib/components/form/connect-location-field.js new file mode 100644 index 000000000..b22262f21 --- /dev/null +++ b/lib/components/form/connect-location-field.js @@ -0,0 +1,53 @@ +import { connect } from 'react-redux' + +import * as apiActions from '../../actions/api' +import * as locationActions from '../../actions/location' +import { getActiveSearch, getShowUserSettings } from '../../util/state' + +/** + * This higher-order component connects the target (styled) LocationField to the + * redux store. + * @param StyledLocationField The input LocationField component to connect. + * @param options Optional object with the following optional props: + * - actions: a list of actions to include in mapDispatchToProps + * - includeLocation: whether to derive the location prop from + * the active query + * @returns The connected component. + */ +export default function connectLocationField (StyledLocationField, options = {}) { + // By default, set actions to empty list and do not include location. + const {actions = [], includeLocation = false} = options + const mapStateToProps = (state, ownProps) => { + const { config, currentQuery, location, transitIndex, user } = state.otp + const { currentPosition, nearbyStops, sessionSearches } = location + const activeSearch = getActiveSearch(state.otp) + const query = activeSearch ? activeSearch.query : currentQuery + + const stateToProps = { + currentPosition, + geocoderConfig: config.geocoder, + nearbyStops, + sessionSearches, + showUserSettings: getShowUserSettings(state.otp), + stopsIndex: transitIndex.stops, + userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] + } + // Set the location prop only if includeLocation is specified, else leave unset. + // Otherwise, the StyledLocationField component will use the fixed undefined/null value as location + // and will not respond to user input. + if (includeLocation) { + stateToProps.location = query[ownProps.locationType] + } + + return stateToProps + } + + const mapDispatchToProps = { + addLocationSearch: locationActions.addLocationSearch, + findNearbyStops: apiActions.findNearbyStops, + getCurrentPosition: locationActions.getCurrentPosition, + ...actions + } + + return connect(mapStateToProps, mapDispatchToProps)(StyledLocationField) +} diff --git a/lib/components/form/connected-links.js b/lib/components/form/connected-links.js new file mode 100644 index 000000000..d7fd9da77 --- /dev/null +++ b/lib/components/form/connected-links.js @@ -0,0 +1,39 @@ +import React from 'react' +import { connect } from 'react-redux' +import { LinkContainer } from 'react-router-bootstrap' +import { Redirect } from 'react-router' +import { Link } from 'react-router-dom' + +/** + * This function enhances the routing components imported above + * by preserving the itinerary search query from the redux state + * when redirecting the user between the main map and account-related pages, + * so that when the user returns to the map, the itinerary that was previously + * displayed is shown again. + * Implementers only need to specify the 'to' route and + * do not need to hook to redux store to retrieve the itinerary search query. + * @param RoutingComponent The routing component to enhance. + * @returns A new component that passes the redux search params to + * the RoutingComponent's 'to' prop. + */ +const withQueryParams = RoutingComponent => + ({ children, queryParams, to, ...props }) => ( + + {children} + + ) + +// For connecting to the redux store +const mapStateToProps = (state, ownProps) => { + return { + queryParams: state.router.location.search + } +} + +// Enhance routing components, connect the result to redux, +// and export. +export default { + LinkWithQuery: connect(mapStateToProps)(withQueryParams(Link)), + LinkContainerWithQuery: connect(mapStateToProps)(withQueryParams(LinkContainer)), + RedirectWithQuery: connect(mapStateToProps)(withQueryParams(Redirect)) +} diff --git a/lib/components/form/connected-location-field.js b/lib/components/form/connected-location-field.js index 1f497f84f..6be2c99d6 100644 --- a/lib/components/form/connected-location-field.js +++ b/lib/components/form/connected-location-field.js @@ -7,13 +7,10 @@ import { InputGroupAddon, MenuItemA } from '@opentripplanner/location-field/lib/styled' -import { connect } from 'react-redux' import styled from 'styled-components' -import { clearLocation, onLocationSelected } from '../../actions/map' -import { addLocationSearch, getCurrentPosition } from '../../actions/location' -import { findNearbyStops } from '../../actions/api' -import { getActiveSearch, getShowUserSettings } from '../../util/state' +import * as mapActions from '../../actions/map' +import connectLocationField from './connect-location-field' const StyledLocationField = styled(LocationField)` width: 100%; @@ -55,31 +52,10 @@ const StyledLocationField = styled(LocationField)` } ` -// connect to redux store - -const mapStateToProps = (state, ownProps) => { - const { config, currentQuery, location, transitIndex, user } = state.otp - const { currentPosition, nearbyStops, sessionSearches } = location - const activeSearch = getActiveSearch(state.otp) - const query = activeSearch ? activeSearch.query : currentQuery - return { - currentPosition, - geocoderConfig: config.geocoder, - location: query[ownProps.locationType], - nearbyStops, - sessionSearches, - showUserSettings: getShowUserSettings(state.otp), - stopsIndex: transitIndex.stops, - userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] - } -} - -const mapDispatchToProps = { - addLocationSearch, - findNearbyStops, - getCurrentPosition, - onLocationSelected, - clearLocation -} - -export default connect(mapStateToProps, mapDispatchToProps)(StyledLocationField) +export default connectLocationField(StyledLocationField, { + actions: { + clearLocation: mapActions.clearLocation, + onLocationSelected: mapActions.onLocationSelected + }, + includeLocation: true +}) diff --git a/lib/components/form/form.css b/lib/components/form/form.css index 56a87cfe9..7bc7a1220 100644 --- a/lib/components/form/form.css +++ b/lib/components/form/form.css @@ -69,6 +69,12 @@ -webkit-appearance: none; } +/* Prevent the calendar picker from having a white margin that covers the date */ +.otp .search-options input[type="date"]::-webkit-calendar-picker-indicator { + background-color: rgba(0, 0, 0, 0.0); + margin-left: 0px; +} + .otp .search-options input[type="time"]::-webkit-inner-spin-button { -webkit-appearance: none; } diff --git a/lib/components/form/intermediate-place-field.js b/lib/components/form/intermediate-place-field.js index 9d3f9aaae..59c1650ba 100644 --- a/lib/components/form/intermediate-place-field.js +++ b/lib/components/form/intermediate-place-field.js @@ -8,13 +8,10 @@ import { MenuItemA } from '@opentripplanner/location-field/lib/styled' import React, {Component} from 'react' -import { connect } from 'react-redux' import styled from 'styled-components' -import { clearLocation } from '../../actions/map' -import { addLocationSearch, getCurrentPosition } from '../../actions/location' -import { findNearbyStops } from '../../actions/api' -import { getShowUserSettings } from '../../util/state' +import * as mapActions from '../../actions/map' +import connectLocationField from './connect-location-field' const StyledIntermediatePlace = styled(LocationField)` width: 100%; @@ -78,27 +75,8 @@ class IntermediatePlaceField extends Component { } } -// connect to redux store - -const mapStateToProps = (state, ownProps) => { - const { config, location, transitIndex, user } = state.otp - const { currentPosition, nearbyStops, sessionSearches } = location - return { - currentPosition, - geocoderConfig: config.geocoder, - nearbyStops, - sessionSearches, - showUserSettings: getShowUserSettings(state.otp), - stopsIndex: transitIndex.stops, - userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] +export default connectLocationField(IntermediatePlaceField, { + actions: { + clearLocation: mapActions.clearLocation } -} - -const mapDispatchToProps = { - addLocationSearch, - findNearbyStops, - getCurrentPosition, - clearLocation -} - -export default connect(mapStateToProps, mapDispatchToProps)(IntermediatePlaceField) +}) diff --git a/lib/components/form/mode-buttons.js b/lib/components/form/mode-buttons.js index 735573c57..110d4409f 100644 --- a/lib/components/form/mode-buttons.js +++ b/lib/components/form/mode-buttons.js @@ -1,9 +1,10 @@ import React, { useContext } from 'react' +import { OverlayTrigger, Tooltip } from 'react-bootstrap' import styled from 'styled-components' import Icon from '../narrative/icon' import { ComponentContext } from '../../util/contexts' -import {buttonCss} from '../app/styled' +import {buttonCss} from './batch-styled' export const MODE_OPTIONS = [ { @@ -37,10 +38,10 @@ const ModeButtons = ({ return MODE_OPTIONS.map((item, index) => ( )) } @@ -54,19 +55,23 @@ const CheckMarkIcon = styled(Icon)` const ModeButton = ({className, item, onClick, selected}) => { const {ModeIcon} = useContext(ComponentContext) + const {icon, label, mode} = item return ( - + + ) } diff --git a/lib/components/form/settings-preview.js b/lib/components/form/settings-preview.js index 212631025..8e8d26ba5 100644 --- a/lib/components/form/settings-preview.js +++ b/lib/components/form/settings-preview.js @@ -4,7 +4,7 @@ import React, { Component } from 'react' import { Button } from 'react-bootstrap' import { connect } from 'react-redux' -import { Dot } from '../app/styled' +import { Dot } from './styled' import { mergeMessages } from '../../util/messages' class SettingsPreview extends Component { diff --git a/lib/components/form/styled.js b/lib/components/form/styled.js index c7ccb2e80..9c2bede6c 100644 --- a/lib/components/form/styled.js +++ b/lib/components/form/styled.js @@ -26,7 +26,7 @@ const commonButtonCss = css` } ` -const commonInputCss = css` +export const commonInputCss = css` background: none; border: 1px solid #ccc; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); @@ -43,137 +43,17 @@ export const modeButtonButtonCss = css` } ` -export const StyledSettingsSelectorPanel = styled(SettingsSelectorPanel)` - ${modeButtonButtonCss} - - ${TripFormClasses.SettingLabel} { - color: #808080; - font-size: 14px; - font-weight: 100; - letter-spacing: 1px; - padding-top: 8px; - text-transform: uppercase; - } - ${TripFormClasses.SettingsHeader} { - color: #333333; - font-size: 18px; - margin: 16px 0px; - } - ${TripFormClasses.SettingsSection} { - margin-bottom: 16px; - } - ${TripFormClasses.DropdownSelector} { - select { - ${commonInputCss} - -webkit-appearance: none; - border-radius: 3px; - font-size: 14px; - height: 34px; - line-height: 1.42857; - margin-bottom: 20px; - - &:focus { - border-color: #66afe9; - box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6); - outline: 0; - } - } - > div:last-child::after { - box-sizing: border-box; - color: #000; - content: "▼"; - font-size: 67%; - pointer-events: none; - position: absolute; - right: 8px; - top: 10px; - } - } - - ${TripFormClasses.ModeSelector} { - font-weight: 300; - ${TripFormClasses.ModeButton.Button} { - box-shadow: none; - outline: none; - padding: 3px; - } - ${TripFormClasses.ModeButton.Title} { - font-size: 10px; - line-height: 12px; - padding: 4px 0px 0px; - - &.active { - font-weight: 600; - } - } - } - ${TripFormClasses.ModeSelector.MainRow} { - box-sizing: border-box; - font-size: 170%; - margin: 0px -10px 18px; - padding: 0px 5px; - ${TripFormClasses.ModeButton.Button} { - height: 54px; - width: 100%; - &.active { - font-weight: 600; - } - } - } - ${TripFormClasses.ModeSelector.SecondaryRow} { - margin: 0px -10px 10px; - ${TripFormClasses.ModeButton.Button} { - font-size: 130%; - font-weight: 800; - height: 46px; - > svg { - margin: 0 0.20em; - } - } - } - ${TripFormClasses.ModeSelector.TertiaryRow} { - font-size: 80%; - font-weight: 300; - margin: 0px -10px 10px; - text-align: center; - ${TripFormClasses.ModeButton.Button} { - height: 36px; - } - } - ${TripFormClasses.SubmodeSelector.Row} { - font-size: 12px; - > * { - padding: 3px 5px 3px 0px; - } - > :last-child { - padding-right: 0px; - } - ${TripFormClasses.ModeButton.Button} { - height: 35px; - } - svg, - img { - margin-left: 0px; - } - } - ${TripFormClasses.SubmodeSelector} { - ${TripFormClasses.SettingLabel} { - margin-bottom: 0; - } - } - ${TripFormClasses.SubmodeSelector.InlineRow} { - margin: -3px 0px; - svg, - img { - height: 18px; - max-width: 32px; - } - } +export const Dot = styled.div` + position: absolute; + top: -3px; + right: -3px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #f00; ` -// FIXME: This is identical to StyledSettingsSelectorPanel, with a -// couple of items set to display: none (SettingsHeader and ModeSelector) -export const StyledBatchSettingsPanel = styled(SettingsSelectorPanel)` +export const StyledSettingsSelectorPanel = styled(SettingsSelectorPanel)` ${modeButtonButtonCss} ${TripFormClasses.SettingLabel} { @@ -185,7 +65,6 @@ export const StyledBatchSettingsPanel = styled(SettingsSelectorPanel)` text-transform: uppercase; } ${TripFormClasses.SettingsHeader} { - display: none; color: #333333; font-size: 18px; margin: 16px 0px; @@ -222,7 +101,6 @@ export const StyledBatchSettingsPanel = styled(SettingsSelectorPanel)` } ${TripFormClasses.ModeSelector} { - display: none; font-weight: 300; ${TripFormClasses.ModeButton.Button} { box-shadow: none; diff --git a/lib/components/map/bounds-updating-overlay.js b/lib/components/map/bounds-updating-overlay.js index 230ae68c8..e4ce308e6 100644 --- a/lib/components/map/bounds-updating-overlay.js +++ b/lib/components/map/bounds-updating-overlay.js @@ -22,6 +22,9 @@ function extendBoundsByPlaces (bounds, places = []) { }) } +/** Padding around itinerary bounds and map bounds. */ +const BOUNDS_PADDING = [30, 30] + /** * This MapLayer component will automatically update the leaflet bounds * depending on what data is in the redux store. This component does not @@ -42,6 +45,29 @@ class BoundsUpdatingOverlay extends MapLayer { componentWillUnmount () {} + _fitItineraryViewToMap (newProps, bounds, map) { + // If itineraryView has changed (currently: only in mobile batch results), + // force a resize of the map before re-fitting the active itinerary or active leg, + // and do that after a delay to ensure that canvas heights have stabilized in the DOM. + setTimeout(() => { + map.invalidateSize(true) + + const { activeLeg, itinerary } = newProps + if (itinerary) { + if (activeLeg !== null) { + // Fit to active leg if set. + map.fitBounds( + getLeafletLegBounds(itinerary.legs[activeLeg]), + { ITINERARY_MAP_PADDING: BOUNDS_PADDING } + ) + } else { + // Fit to whole itinerary otherwise. + map.fitBounds(bounds, { ITINERARY_MAP_PADDING: BOUNDS_PADDING }) + } + } + }, 250) + } + /* eslint-disable-next-line complexity */ updateBounds (oldProps, newProps) { // TODO: maybe setting bounds ought to be handled in map props... @@ -55,8 +81,6 @@ class BoundsUpdatingOverlay extends MapLayer { const { map } = newProps.leaflet if (!map) return - const padding = [30, 30] - // Fit map to to entire itinerary if active itinerary bounds changed const newFrom = newProps.query && newProps.query.from const newItinBounds = newProps.itinerary && getLeafletItineraryBounds(newProps.itinerary) @@ -69,11 +93,17 @@ class BoundsUpdatingOverlay extends MapLayer { const oldIntermediate = oldProps.query && oldProps.query.intermediatePlaces const newIntermediate = newProps.query && newProps.query.intermediatePlaces const intermediateChanged = !isEqual(oldIntermediate, newIntermediate) - if ( + + // Also refit map if itineraryView prop has changed. + const itineraryViewChanged = oldProps.itineraryView !== newProps.itineraryView + + if (itineraryViewChanged) { + this._fitItineraryViewToMap(newProps, newItinBounds, map) + } else if ( (!oldItinBounds && newItinBounds) || (oldItinBounds && newItinBounds && !oldItinBounds.equals(newItinBounds)) ) { - map.fitBounds(newItinBounds, { padding }) + map.fitBounds(newItinBounds, { padding: BOUNDS_PADDING }) // Pan to to itinerary leg if made active (clicked); newly active leg must be non-null } else if ( newProps.itinerary && @@ -82,7 +112,7 @@ class BoundsUpdatingOverlay extends MapLayer { ) { map.fitBounds( getLeafletLegBounds(newProps.itinerary.legs[newProps.activeLeg]), - { padding } + { padding: BOUNDS_PADDING } ) // If no itinerary update but from/to locations are present, fit to those @@ -108,9 +138,8 @@ class BoundsUpdatingOverlay extends MapLayer { map.fitBounds([ [left, bottom], [right, top] - ], { padding }) + ], { padding: BOUNDS_PADDING }) } - // If only from or to is set, pan to that } else if (newFrom && fromChanged) { map.panTo([newFrom.lat, newFrom.lon]) @@ -141,10 +170,13 @@ class BoundsUpdatingOverlay extends MapLayer { const mapStateToProps = (state, ownProps) => { const activeSearch = getActiveSearch(state.otp) + const urlParams = coreUtils.query.getUrlParams() + return { activeLeg: activeSearch && activeSearch.activeLeg, activeStep: activeSearch && activeSearch.activeStep, itinerary: getActiveItinerary(state.otp), + itineraryView: urlParams.ui_itineraryView, popupLocation: state.otp.ui.mapPopupLocation, query: state.otp.currentQuery } diff --git a/lib/components/map/default-map.js b/lib/components/map/default-map.js index 3ce357508..9a3157701 100644 --- a/lib/components/map/default-map.js +++ b/lib/components/map/default-map.js @@ -1,4 +1,5 @@ import BaseMap from '@opentripplanner/base-map' +import coreUtils from '@opentripplanner/core-utils' import React, { Component } from 'react' import { connect } from 'react-redux' import styled from 'styled-components' @@ -119,6 +120,7 @@ class DefaultMap extends Component { bikeRentalStations, carRentalQuery, carRentalStations, + itineraryView, mapConfig, mapPopupLocation, vehicleRentalQuery, @@ -146,8 +148,8 @@ class DefaultMap extends Component { center={center} maxZoom={mapConfig.maxZoom} onClick={this.onMapClick} - popup={popup} onPopupClosed={this.onPopupClosed} + popup={popup} zoom={mapConfig.initZoom || 13} > {/* The default overlays */} @@ -155,7 +157,14 @@ class DefaultMap extends Component { - + {/* + HACK: Use the key prop to force a remount and full resizing of transitive + if the map container size changes, + per https://linguinecode.com/post/4-methods-to-re-render-react-component + Without it, transitive resolution will not match the map, + and transitive will appear blurry after e.g. the narrative is expanded. + */} + @@ -206,9 +215,12 @@ const mapStateToProps = (state, ownProps) => { const overlays = state.otp.config.map && state.otp.config.map.overlays ? state.otp.config.map.overlays : [] + const urlParams = coreUtils.query.getUrlParams() + return { bikeRentalStations: state.otp.overlay.bikeRental.stations, carRentalStations: state.otp.overlay.carRental.stations, + itineraryView: urlParams.ui_itineraryView, mapConfig: state.otp.config.map, mapPopupLocation: state.otp.ui.mapPopupLocation, overlays, diff --git a/lib/components/mobile/batch-results-screen.js b/lib/components/mobile/batch-results-screen.js new file mode 100644 index 000000000..51370a897 --- /dev/null +++ b/lib/components/mobile/batch-results-screen.js @@ -0,0 +1,146 @@ +import coreUtils from '@opentripplanner/core-utils' +import React from 'react' +import { Button } from 'react-bootstrap' +import { connect } from 'react-redux' +import styled, { css } from 'styled-components' + +import * as uiActions from '../../actions/ui' +import Map from '../map/map' +import Icon from '../narrative/icon' +import NarrativeItineraries from '../narrative/narrative-itineraries' +import { + getActiveItineraries, + getActiveSearch, + getResponsesWithErrors +} from '../../util/state' + +import MobileContainer from './container' +import ResultsError from './results-error' +import ResultsHeader from './results-header' + +const StyledMobileContainer = styled(MobileContainer)` + .options > .header { + margin: 10px; + } + + &.otp.mobile .mobile-narrative-container { + bottom: 0; + left: 0; + overflow-y: auto; + padding: 0; + position: fixed; + right: 0; + } +` + +const ExpandMapButton = styled(Button)` + bottom: 10px; + left: 10px; + position: absolute; + z-index: 999999; +` + +const NARRATIVE_SPLIT_TOP_PERCENT = 45 + +// Styles for the results map also include prop-independent styles copied from mobile.css. +const ResultsMap = styled.div` + bottom: ${props => props.expanded ? '0' : `${100 - NARRATIVE_SPLIT_TOP_PERCENT}%`}; + display: ${props => props.visible ? 'inherit' : 'none'}; + left: 0; + position: fixed; + right: 0; + top: 100px; +` + +const narrativeCss = css` + top: ${props => props.visible ? (props.expanded ? '100px' : `${NARRATIVE_SPLIT_TOP_PERCENT}%`) : '100%'}; + transition: top 300ms; +` + +const StyledResultsError = styled(ResultsError)` + display: ${props => props.visible ? 'inherit' : 'none'}; + ${narrativeCss} +` + +const NarrativeContainer = styled.div` + ${narrativeCss} +` + +const { ItineraryView } = uiActions + +/** + * This component renders the mobile view of itinerary results from batch routing, + * and features a split view between the map and itinerary results or narratives. + */ +const BatchMobileResultsScreen = ({ + errors, + itineraries, + itineraryView, + toggleBatchResultsMap +}) => { + const hasErrorsAndNoResult = itineraries.length === 0 && errors.length > 0 + const mapExpanded = itineraryView === ItineraryView.LEG_HIDDEN || itineraryView === ItineraryView.LIST_HIDDEN + const itineraryExpanded = itineraryView === ItineraryView.FULL + + return ( + + + + + + {' '} + {mapExpanded ? 'Show results' : 'Expand map'} + + + {hasErrorsAndNoResult + ? + : ( + + + + ) + } + + ) +} + +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const activeSearch = getActiveSearch(state.otp) + const urlParams = coreUtils.query.getUrlParams() + return { + activeLeg: activeSearch ? activeSearch.activeLeg : null, + errors: getResponsesWithErrors(state.otp), + itineraries: getActiveItineraries(state.otp), + itineraryView: urlParams.ui_itineraryView || ItineraryView.DEFAULT + } +} + +const mapDispatchToProps = { + toggleBatchResultsMap: uiActions.toggleBatchResultsMap +} + +export default connect(mapStateToProps, mapDispatchToProps)(BatchMobileResultsScreen) diff --git a/lib/components/mobile/batch-search-screen.js b/lib/components/mobile/batch-search-screen.js new file mode 100644 index 000000000..e6582fa96 --- /dev/null +++ b/lib/components/mobile/batch-search-screen.js @@ -0,0 +1,71 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' + +import BatchSettings from '../form/batch-settings' +import DefaultMap from '../map/default-map' +import LocationField from '../form/connected-location-field' +import SwitchButton from '../form/switch-button' + +import MobileContainer from './container' +import MobileNavigationBar from './navigation-bar' + +import { MobileScreens, setMobileScreen } from '../../actions/ui' + +const { + SET_DATETIME, + SET_FROM_LOCATION, + SET_TO_LOCATION +} = MobileScreens + +class BatchSearchScreen extends Component { + static propTypes = { + map: PropTypes.element, + setMobileScreen: PropTypes.func + } + + _fromFieldClicked = () => this.props.setMobileScreen(SET_FROM_LOCATION) + + _toFieldClicked = () => this.props.setMobileScreen(SET_TO_LOCATION) + + _expandDateTimeClicked = () => this.props.setMobileScreen(SET_DATETIME) + + render () { + return ( + + +
    + + +
    + } /> +
    + +
    +
    + +
    +
    + ) + } +} + +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + return { } +} + +const mapDispatchToProps = { + setMobileScreen +} + +export default connect(mapStateToProps, mapDispatchToProps)(BatchSearchScreen) diff --git a/lib/components/mobile/container.js b/lib/components/mobile/container.js index fc79f524e..0795ae7c7 100644 --- a/lib/components/mobile/container.js +++ b/lib/components/mobile/container.js @@ -2,9 +2,10 @@ import React, { Component } from 'react' export default class MobileContainer extends Component { render () { + const { children, className } = this.props return ( -
    - {this.props.children} +
    + {children}
    ) } diff --git a/lib/components/mobile/edit-search-button.js b/lib/components/mobile/edit-search-button.js new file mode 100644 index 000000000..bdf514857 --- /dev/null +++ b/lib/components/mobile/edit-search-button.js @@ -0,0 +1,20 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import { connect } from 'react-redux' + +import * as uiActions from '../../actions/ui' + +/** + * Renders the "Edit" or "Back to search" button in mobile result views + * that takes the user back to the mobile search screen. + */ +const EditSearchButton = ({ showMobileSearchScreen, ...props }) => ( + - - - - ) - - if (error) { - return ( - - - {locationsSummary} -
    -
    - -
    - -
    -
    -
    - ) - } - - // Construct the 'dots' - const dots = [] - for (let i = 0; i < resultCount; i++) { - dots.push(
    ) - } - return ( - 1 ? 's' : ''}` - : 'Waiting...' - } - headerAction={headerAction} - /> - {locationsSummary} - -
    - {this.props.map} + +
    +
    - -
    - Option {activeItineraryIndex + 1} - -
    - -
    - -
    - -
    {dots}
    + {error + ? + : ( + <> + + Option {activeItineraryIndex + 1} + + + + + + + {this.renderDots()} + + ) + } ) } @@ -236,25 +166,23 @@ const mapStateToProps = (state, ownProps) => { const realtimeEffects = getRealtimeEffects(state.otp) const itineraries = getActiveItineraries(state.otp) return { + activeItineraryIndex: activeSearch ? activeSearch.activeItinerary : null, + activeLeg: activeSearch ? activeSearch.activeLeg : null, + error: getActiveError(state.otp), query: state.otp.currentQuery, realtimeEffects, - error: getActiveError(state.otp), resultCount: response ? activeSearch.query.routingType === 'ITINERARY' ? itineraries.length : response.otp.profile.length : null, - useRealtime, - activeItineraryIndex: activeSearch ? activeSearch.activeItinerary : null, - activeLeg: activeSearch ? activeSearch.activeLeg : null + useRealtime } } const mapDispatchToProps = { - clearActiveSearch, - setMobileScreen, - setUseRealtimeResponse + setUseRealtimeResponse: narrativeActions.setUseRealtimeResponse } export default connect(mapStateToProps, mapDispatchToProps)(MobileResultsScreen) diff --git a/lib/components/mobile/welcome-screen.js b/lib/components/mobile/welcome-screen.js index d31712c05..31ced700b 100644 --- a/lib/components/mobile/welcome-screen.js +++ b/lib/components/mobile/welcome-screen.js @@ -12,8 +12,6 @@ import { setLocationToCurrent } from '../../actions/map' class MobileWelcomeScreen extends Component { static propTypes = { - map: PropTypes.element, - setLocationToCurrent: PropTypes.func, setMobileScreen: PropTypes.func } @@ -36,10 +34,9 @@ class MobileWelcomeScreen extends Component { } render () { - const { title } = this.props return ( - +
    {showRealtimeAnnotation && } - + }
    diff --git a/lib/components/narrative/itinerary-carousel.js b/lib/components/narrative/itinerary-carousel.js index 631475db7..912126d88 100644 --- a/lib/components/narrative/itinerary-carousel.js +++ b/lib/components/narrative/itinerary-carousel.js @@ -86,17 +86,21 @@ class ItineraryCarousel extends Component { index={activeItinerary} onChangeIndex={this._onSwipe} > - {itineraries.map((itinerary, index) => ( - - ))} + {itineraries.map((itinerary, index) => { + const active = index === activeItinerary + return ( + + ) + })}
    ) diff --git a/lib/components/narrative/line-itin/realtime-time-column.js b/lib/components/narrative/line-itin/realtime-time-column.js index 76cc45e94..1208d1afc 100644 --- a/lib/components/narrative/line-itin/realtime-time-column.js +++ b/lib/components/narrative/line-itin/realtime-time-column.js @@ -8,47 +8,17 @@ import PropTypes from 'prop-types' import React from 'react' import styled from 'styled-components' -const TimeText = styled.div`` +import RealtimeStatusLabel, { DelayText, MainContent } from '../../viewers/realtime-status-label' -const TimeStruck = styled.div` - text-decoration: line-through; -` - -const TimeBlock = styled.div` - line-height: 1em; - margin-bottom: 4px; -` - -const TimeColumnBase = styled.div`` - -const StatusText = styled.div` - color: #bbb; - font-size: 80%; - line-height: 1em; -` - -const DelayText = styled.span` - display: block; - white-space: nowrap; -` - -// Reusing stop viewer colors. -const TimeColumnOnTime = styled(TimeColumnBase)` - ${TimeText}, ${StatusText} { - color: #5cb85c; +const StyledStatusLabel = styled(RealtimeStatusLabel)` + ${MainContent} { + font-size: 80%; + line-height: 1em; } -` -const TimeColumnEarly = styled(TimeColumnBase)` - ${TimeText}, ${StatusText} { - color: #337ab7; - } -` -const TimeColumnLate = styled(TimeColumnBase)` - ${TimeText}, ${StatusText} { - color: #d9534f; + ${DelayText} { + display: block; } ` - /** * This component displays the scheduled departure/arrival time for a leg, * and, for transit legs, displays any delays or earliness where applicable. @@ -61,75 +31,27 @@ function RealtimeTimeColumn ({ const time = isDestination ? leg.endTime : leg.startTime const formattedTime = time && formatTime(time, timeOptions) const isTransitLeg = isTransit(leg.mode) + const isRealtimeTransitLeg = isTransitLeg && leg.realTime - // For non-real-time legs, show only the scheduled time, - // except for transit legs where we add the "scheduled" text underneath. - if (!leg.realTime) { - return ( - <> - {formattedTime} - {isTransitLeg && scheduled} - - ) + // For non-transit legs show only the scheduled time. + if (!isTransitLeg) { + return
    {formattedTime}
    } - // Delay in seconds. - const delay = isDestination ? leg.arrivalDelay : leg.departureDelay - // Time is in milliseconds. - const originalTime = time - delay * 1000 + const delaySeconds = isDestination ? leg.arrivalDelay : leg.departureDelay + const originalTimeMillis = time - delaySeconds * 1000 const originalFormattedTime = - originalTime && formatTime(originalTime, timeOptions) - - // TODO: refine on-time thresholds. - // const isOnTime = delay >= -60 && delay <= 120; - const isOnTime = delay === 0 - - let statusText - let TimeColumn = TimeColumnBase - if (isOnTime) { - statusText = 'on time' - TimeColumn = TimeColumnOnTime - } else if (delay < 0) { - statusText = 'early' - TimeColumn = TimeColumnEarly - } else if (delay > 0) { - statusText = 'late' - TimeColumn = TimeColumnLate - } - - // Absolute delay in rounded minutes, for display purposes. - const delayInMinutes = Math.abs( - Math.round((isDestination ? leg.arrivalDelay : leg.departureDelay) / 60) - ) - - let renderedTime - if (!isOnTime) { - // If the transit vehicle is not on time, strike the original scheduled time - // and display the updated time underneath. - renderedTime = ( - - {!isOnTime && {originalFormattedTime}} - {formattedTime} - - ) - } else { - renderedTime = {formattedTime} - } + originalTimeMillis && formatTime(originalTimeMillis, timeOptions) return ( - - {renderedTime} - - {/* Keep the '5 min' string on the same line. */} - {!isOnTime && {delayInMinutes} min} - {statusText} - - + ) } -export default RealtimeTimeColumn - RealtimeTimeColumn.propTypes = { isDestination: PropTypes.bool.isRequired, leg: legType.isRequired, @@ -139,3 +61,5 @@ RealtimeTimeColumn.propTypes = { RealtimeTimeColumn.defaultProps = { timeOptions: null } + +export default RealtimeTimeColumn diff --git a/lib/components/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js index eb8585e9a..b241ac753 100644 --- a/lib/components/narrative/narrative-itineraries.js +++ b/lib/components/narrative/narrative-itineraries.js @@ -12,16 +12,20 @@ import { setVisibleItinerary, updateItineraryFilter } from '../../actions/narrative' +import * as uiActions from '../../actions/ui' import Icon from '../narrative/icon' import { ComponentContext } from '../../util/contexts' import { - getResponsesWithErrors, getActiveItineraries, getActiveSearch, - getRealtimeEffects + getRealtimeEffects, + getResponsesWithErrors } from '../../util/state' + import SaveTripButton from './save-trip-button' +const { ItineraryView } = uiActions + // TODO: move to utils? function humanReadableMode (modeStr) { if (!modeStr) return 'N/A' @@ -36,41 +40,60 @@ function humanReadableMode (modeStr) { class NarrativeItineraries extends Component { static propTypes = { + activeItinerary: PropTypes.number, containerStyle: PropTypes.object, itineraries: PropTypes.array, pending: PropTypes.bool, - activeItinerary: PropTypes.number, setActiveItinerary: PropTypes.func, setActiveLeg: PropTypes.func, setActiveStep: PropTypes.func, + setItineraryView: PropTypes.func, setUseRealtimeResponse: PropTypes.func, useRealtime: PropTypes.bool } static contextType = ComponentContext - state = {} + _setActiveLeg = (index, leg) => { + const { activeLeg, setActiveLeg, setItineraryView } = this.props + const isSameLeg = activeLeg === index + if (isSameLeg) { + // If clicking on the same leg again, reset it to null, + // and show the full itinerary (both desktop and mobile view) + setActiveLeg(null, null) + setItineraryView(ItineraryView.FULL) + } else { + // Focus on the newly selected leg. + setActiveLeg(index, leg) + setItineraryView(ItineraryView.LEG) + } + } - _toggleDetailedItinerary = () => { - this.setState({showDetails: !this.state.showDetails}) + _isShowingDetails = () => { + const { itineraryView } = this.props + return itineraryView === ItineraryView.FULL || + itineraryView === ItineraryView.LEG || + itineraryView === ItineraryView.LEG_HIDDEN } - _onFilterChange = evt => { - const {sort, updateItineraryFilter} = this.props - const {value} = evt.target - updateItineraryFilter({filter: value, sort}) + _toggleDetailedItinerary = () => { + const { setActiveLeg, setItineraryView } = this.props + const newView = this._isShowingDetails() ? ItineraryView.LIST : ItineraryView.FULL + setItineraryView(newView) + // Reset the active leg. + setActiveLeg(null, null) } _onSortChange = evt => { const {value: type} = evt.target - const {filter, sort, updateItineraryFilter} = this.props - updateItineraryFilter({filter, sort: {...sort, type}}) + const {sort, updateItineraryFilter} = this.props + updateItineraryFilter({sort: {...sort, type}}) } _onSortDirChange = () => { - const {filter, sort, updateItineraryFilter} = this.props + const {sort, updateItineraryFilter} = this.props const direction = sort.direction === 'ASC' ? 'DESC' : 'ASC' - updateItineraryFilter({filter, sort: {...sort, direction}}) + updateItineraryFilter({sort: {...sort, direction}}) } _toggleRealtimeItineraryClick = (e) => { @@ -99,20 +122,27 @@ class NarrativeItineraries extends Component { render () { const { activeItinerary, + activeLeg, activeSearch, + activeStep, containerStyle, errors, - filter, itineraries, pending, realtimeEffects, + setActiveItinerary, + setActiveStep, + setVisibleItinerary, sort, - useRealtime + timeFormat, + useRealtime, + visibleItinerary } = this.props const { ItineraryBody, LegIcon } = this.context if (!activeSearch) return null - const itineraryIsExpanded = activeItinerary !== undefined && activeItinerary !== null && this.state.showDetails + const showDetails = this._isShowingDetails() + const itineraryIsExpanded = activeItinerary !== undefined && activeItinerary !== null && showDetails const showRealtimeAnnotation = realtimeEffects.isAffectedByRealtimeData && ( realtimeEffects.exceedsThreshold || @@ -122,6 +152,7 @@ class NarrativeItineraries extends Component { const resultText = pending ? 'Finding your options...' : `${itineraries.length} itineraries found.` + return (
    {resultText}
    - { // FIXME: Enable only when ITINERARY/BATCH routing type enabled. - - }
    - @@ -196,21 +219,29 @@ class NarrativeItineraries extends Component { return ( ) })} + {/* Don't show errors if an itinerary is expanded. */} {/* FIXME: Flesh out error design/move to component? */} - {errors.map((e, i) => { + {!itineraryIsExpanded && errors.map((e, i) => { const mode = humanReadableMode(e.requestParameters.mode) return (
    @@ -234,23 +265,25 @@ class NarrativeItineraries extends Component { const mapStateToProps = (state, ownProps) => { const activeSearch = getActiveSearch(state.otp) const {modes} = state.otp.config - const {filter, sort} = state.otp.filter + const {sort} = state.otp.filter const pending = activeSearch ? Boolean(activeSearch.pending) : false const itineraries = getActiveItineraries(state.otp) const realtimeEffects = getRealtimeEffects(state.otp) const useRealtime = state.otp.useRealtime + const urlParams = coreUtils.query.getUrlParams() + return { - activeSearch, - errors: getResponsesWithErrors(state.otp), // swap out realtime itineraries with non-realtime depending on boolean - itineraries, - pending, - realtimeEffects, activeItinerary: activeSearch && activeSearch.activeItinerary, activeLeg: activeSearch && activeSearch.activeLeg, + activeSearch, activeStep: activeSearch && activeSearch.activeStep, - filter, + errors: getResponsesWithErrors(state.otp), + itineraries, + itineraryView: urlParams.ui_itineraryView || ItineraryView.DEFAULT, modes, + pending, + realtimeEffects, sort, timeFormat: coreUtils.time.getTimeFormat(state.otp.config), useRealtime, @@ -272,6 +305,7 @@ const mapDispatchToProps = (dispatch, ownProps) => { setActiveStep: (index, step) => { dispatch(setActiveStep({index, step})) }, + setItineraryView: payload => dispatch(uiActions.setItineraryView(payload)), setUseRealtimeResponse: payload => dispatch(setUseRealtimeResponse(payload)), setVisibleItinerary: payload => dispatch(setVisibleItinerary(payload)), updateItineraryFilter: payload => dispatch(updateItineraryFilter(payload)) diff --git a/lib/components/narrative/save-trip-button.js b/lib/components/narrative/save-trip-button.js index 5a46e6af7..af80a07fa 100644 --- a/lib/components/narrative/save-trip-button.js +++ b/lib/components/narrative/save-trip-button.js @@ -1,8 +1,8 @@ import React from 'react' import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { connect } from 'react-redux' -import { LinkContainer } from 'react-router-bootstrap' +import { LinkContainerWithQuery } from '../form/connected-links' import { CREATE_TRIP_PATH } from '../../util/constants' import { itineraryCanBeMonitored } from '../../util/itinerary' import { getActiveItinerary } from '../../util/state' @@ -14,8 +14,7 @@ import { getActiveItinerary } from '../../util/state' const SaveTripButton = ({ itinerary, loggedInUser, - persistence, - queryParams + persistence }) => { // We are dealing with the following states: // 1. Persistence disabled => just return null @@ -69,9 +68,9 @@ const SaveTripButton = ({ } return ( - + {button} - + ) } @@ -82,8 +81,7 @@ const mapStateToProps = (state, ownProps) => { return { itinerary: getActiveItinerary(state.otp), loggedInUser: state.user.loggedInUser, - persistence, - queryParams: state.router.location.search + persistence } } diff --git a/lib/components/user/account-page.js b/lib/components/user/account-page.js index 3807dc563..4c7042e63 100644 --- a/lib/components/user/account-page.js +++ b/lib/components/user/account-page.js @@ -1,13 +1,17 @@ import { withAuthenticationRequired } from '@auth0/auth0-react' +import { replace } from 'connected-react-router' import React, { Component } from 'react' -import { Col, Row } from 'react-bootstrap' import { connect } from 'react-redux' import * as uiActions from '../../actions/ui' -import { CREATE_ACCOUNT_PATH } from '../../util/constants' +import { + CREATE_ACCOUNT_PATH, + CREATE_ACCOUNT_TERMS_PATH, + CREATE_ACCOUNT_VERIFY_PATH +} from '../../util/constants' import { RETURN_TO_CURRENT_ROUTE } from '../../util/ui' import withLoggedInUserSupport from './with-logged-in-user-support' -import DesktopNav from '../app/desktop-nav' +import AppFrame from '../app/app-frame' import SubNav from './sub-nav' /** @@ -15,33 +19,31 @@ import SubNav from './sub-nav' * wrap any user account page (e.g., SavedTripList or account settings). */ class AccountPage extends Component { - componentDidMount () { - const { loggedInUser, routeTo } = this.props + /** + * If a user signed up in Auth0 and did not complete the New Account wizard + * (and they are not on or have not just left the Terms and Conditions page), + * make the user finish set up their accounts first. + * monitoredTrips should not be null otherwise. + * NOTE: This check applies to any route that makes use of this component. + */ + _checkAccountCreated = () => { + const { isTermsOrVerifyPage, loggedInUser, routeTo } = this.props - if (!loggedInUser.hasConsentedToTerms) { - // If a user signed up in Auth0 and did not complete the New Account wizard - // make the user finish set up their accounts first. - // monitoredTrips should not be null otherwise. - // NOTE: This check applies to any route that makes use of this component. - routeTo(CREATE_ACCOUNT_PATH) + if (!loggedInUser.hasConsentedToTerms && !isTermsOrVerifyPage) { + routeTo(CREATE_ACCOUNT_PATH, null, replace) } } + componentDidMount () { + this._checkAccountCreated() + } + render () { const {children, subnav = true} = this.props return ( -
    - {/* TODO: Do mobile view. */} - - {subnav && } -
    - - - {children} - - -
    -
    + + {children} + ) } } @@ -49,9 +51,11 @@ class AccountPage extends Component { // connect to the redux store const mapStateToProps = (state, ownProps) => { + const currentPath = state.router.location.pathname return { - loggedInUser: state.user.loggedInUser, - trips: state.user.loggedInUserMonitoredTrips + isTermsOrVerifyPage: + currentPath === CREATE_ACCOUNT_TERMS_PATH || currentPath === CREATE_ACCOUNT_VERIFY_PATH, + loggedInUser: state.user.loggedInUser } } diff --git a/lib/components/user/back-link.js b/lib/components/user/back-link.js new file mode 100644 index 000000000..7f2cc82e5 --- /dev/null +++ b/lib/components/user/back-link.js @@ -0,0 +1,27 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import styled from 'styled-components' + +import { IconWithMargin } from './styled' + +const StyledButton = styled(Button)` + display: block; + padding: 0; +` + +const navigateBack = () => window.history.back() + +/** + * Back link that navigates to the previous location in browser history. + */ +const BackLink = () => ( + + + Back + +) + +export default BackLink diff --git a/lib/components/user/back-to-trip-planner.js b/lib/components/user/back-to-trip-planner.js index ce3cb9894..fd0787da8 100644 --- a/lib/components/user/back-to-trip-planner.js +++ b/lib/components/user/back-to-trip-planner.js @@ -1,13 +1,18 @@ import React from 'react' import styled from 'styled-components' -import { Link } from 'react-router-dom' -const Container = styled.div`` +import { LinkWithQuery } from '../form/connected-links' +import { IconWithMargin } from './styled' + +const StyledLinkWithQuery = styled(LinkWithQuery)` + display: block; +` const BackToTripPlanner = () => ( - - ← Back to trip planner - + + + Back to trip planner + ) export default BackToTripPlanner diff --git a/lib/components/user/delete-user.js b/lib/components/user/delete-user.js new file mode 100644 index 000000000..7aaaec235 --- /dev/null +++ b/lib/components/user/delete-user.js @@ -0,0 +1,23 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import styled from 'styled-components' + +const DeleteButton = styled(Button)` + background-color: white; + color: #d9534f; + :hover, :focus, :active, :focus:active { + background-color: #f7f7f7; + color: #d9534f; + } +` + +/** + * Renders a delete user button for the account settings page. + */ +const DeleteUser = ({onDelete}) => ( + + Delete my account + +) + +export default DeleteUser diff --git a/lib/components/user/existing-account-display.js b/lib/components/user/existing-account-display.js index 3c698befc..78ceabe8a 100644 --- a/lib/components/user/existing-account-display.js +++ b/lib/components/user/existing-account-display.js @@ -1,6 +1,11 @@ import React from 'react' +import BackToTripPlanner from './back-to-trip-planner' +import DeleteUser from './delete-user' +import NotificationPrefsPane from './notification-prefs-pane' +import FavoritePlacesList from './places/favorite-places-list' import StackedPaneDisplay from './stacked-pane-display' +import TermsOfUsePane from './terms-of-use-pane' /** * This component handles the existing account display. @@ -10,26 +15,31 @@ const ExistingAccountDisplay = props => { // and to its own blur/change/submit event handlers that automate the state. // We forward the props to each pane so that their individual controls // can be wired to be managed by Formik. - const { onCancel, panes } = props + const { onCancel } = props const paneSequence = [ { - pane: panes.locations, + pane: FavoritePlacesList, props, title: 'My locations' }, { - pane: panes.notifications, + pane: NotificationPrefsPane, props, title: 'Notifications' }, { - pane: panes.terms, + pane: TermsOfUsePane, props: { ...props, disableCheckTerms: true }, title: 'Terms' + }, + { + pane: DeleteUser, + props } ] return (
    + loc.type === 'home' -export const isWork = loc => loc.type === 'work' - -/** - * Helper function that adds a new address to the Formik state - * using the Formik-provided arrayHelpers object. - */ -function addNewAddress (arrayHelpers, e) { - const value = (e.target.value || '').trim() - if (value.length > 0) { - arrayHelpers.push({ - address: value, - icon: 'map-marker', - type: 'custom' - }) - - // Empty the input box value so the user can enter their next location. - e.target.value = '' - } -} - -/** - * User's saved locations editor. - * TODO: Discuss and improve handling of location details (type, coordinates...). - */ -class FavoriteLocationsPane extends Component { - _handleNewAddressKeyDown = memoize( - arrayHelpers => e => { - if (e.keyCode === 13) { - // On the user pressing enter (keyCode 13) on the new location input, - // add new address to user's savedLocations... - addNewAddress(arrayHelpers, e) - - // ... but don't submit the form. - e.preventDefault() - } - } - ) - - _handleNewAddressBlur = memoize( - arrayHelpers => e => { - addNewAddress(arrayHelpers, e) - } - ) - - render () { - const { values: userData } = this.props - const { savedLocations } = userData - const homeLocation = savedLocations.find(isHome) - const workLocation = savedLocations.find(isWork) - - return ( -
    - Add the places you frequent often to save time planning trips: - - ( - <> - {savedLocations.map((loc, index) => { - const isHomeOrWork = loc === homeLocation || loc === workLocation - return ( - - - - - - - {!isHomeOrWork && ( - - - - )} - - - ) - })} - - {/* For adding a new location. */} - - - - - - - - - - )} - /> -
    - ) - } -} - -export default FavoriteLocationsPane diff --git a/lib/components/user/monitored-trip/saved-trip-editor.js b/lib/components/user/monitored-trip/saved-trip-editor.js index 1db108f16..ef7994be8 100644 --- a/lib/components/user/monitored-trip/saved-trip-editor.js +++ b/lib/components/user/monitored-trip/saved-trip-editor.js @@ -1,5 +1,6 @@ import React from 'react' +import BackLink from '../back-link' import StackedPaneDisplay from '../stacked-pane-display' /** @@ -32,6 +33,7 @@ const SavedTripEditor = props => { return (
    + { - const { isCreating, createOrUpdateUserMonitoredTrip } = this.props + const { createOrUpdateUserMonitoredTrip, isCreating } = this.props createOrUpdateUserMonitoredTrip(monitoredTrip, isCreating) } @@ -146,11 +148,11 @@ class SavedTripScreen extends Component { screenContents = ( diff --git a/lib/components/user/monitored-trip/trip-basics-pane.js b/lib/components/user/monitored-trip/trip-basics-pane.js index 77a272f79..fa0210d43 100644 --- a/lib/components/user/monitored-trip/trip-basics-pane.js +++ b/lib/components/user/monitored-trip/trip-basics-pane.js @@ -1,4 +1,5 @@ import { Field } from 'formik' +import FormikErrorFocus from 'formik-error-focus' import React, { Component } from 'react' import { ControlLabel, @@ -12,6 +13,7 @@ import { connect } from 'react-redux' import styled from 'styled-components' import * as userActions from '../../../actions/user' +import { getErrorStates } from '../../../util/ui' import TripStatus from './trip-status' import TripSummary from './trip-summary' @@ -51,6 +53,25 @@ const allDays = [ * and lets the user edit the trip name and day. */ class TripBasicsPane extends Component { + /** + * For new trips only, update the Formik state to + * uncheck days for which the itinerary is not available. + */ + _updateNewTripItineraryExistence = prevProps => { + const { isCreating, itineraryExistence, setFieldValue } = this.props + + if (isCreating && + itineraryExistence && + itineraryExistence !== prevProps.itineraryExistence + ) { + allDays.forEach(({ name }) => { + if (!itineraryExistence[name].valid) { + setFieldValue(name, false) + } + }) + } + } + componentDidMount () { // Check itinerary availability (existence) for all days. const { checkItineraryExistence, values: monitoredTrip } = this.props @@ -58,19 +79,7 @@ class TripBasicsPane extends Component { } 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) - } - }) - } - } + this._updateNewTripItineraryExistence(prevProps) } componentWillUnmount () { @@ -78,26 +87,21 @@ class TripBasicsPane extends Component { } render () { - const { errors, isCreating, itineraryExistence, touched, values: monitoredTrip } = this.props + const { errors, isCreating, itineraryExistence, 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 - } - - // Show a combined error indicaton when no day is selected. + // Show an error indication when + // - monitoredTrip.tripName is not blank and that tripName is not already used. + // - no day is selected (show a combined error indication). + const errorStates = getErrorStates(this.props) + let monitoredDaysValidationState = null allDays.forEach(({ name }) => { - if (touched[name]) { - if (!monitoredDaysValidationState) { - monitoredDaysValidationState = errors[name] ? 'error' : null - } + if (!monitoredDaysValidationState) { + monitoredDaysValidationState = errorStates[name] } }) @@ -109,7 +113,7 @@ class TripBasicsPane extends Component { Selected itinerary: - + Please provide a name for this trip: {/* onBlur, onChange, and value are passed automatically. */} @@ -149,6 +153,9 @@ class TripBasicsPane extends Component { } {monitoredDaysValidationState && Please select at least one day to monitor.} + + {/* Scroll to the trip name/days fields if submitting and there is an error on these fields. */} +
    ) diff --git a/lib/components/user/monitored-trip/trip-notifications-pane.js b/lib/components/user/monitored-trip/trip-notifications-pane.js index 983a2552c..302e928d4 100644 --- a/lib/components/user/monitored-trip/trip-notifications-pane.js +++ b/lib/components/user/monitored-trip/trip-notifications-pane.js @@ -1,32 +1,107 @@ import { Field } from 'formik' import React, { Component } from 'react' -import { Alert, Checkbox, ControlLabel, FormControl, FormGroup, Glyphicon, HelpBlock, Radio } from 'react-bootstrap' +import { Alert, FormControl, Glyphicon } from 'react-bootstrap' +import styled from 'styled-components' + +import Icon from '../../narrative/icon' const notificationChannelLabels = { email: 'email', sms: 'SMS' } +// Element styles +const SettingsList = styled.ul` + border-spacing: 0 10px; + display: table; + padding-left: 0; + width: 100%; + label { + font-weight: inherit; + } + & > li { + align-items: center; + display: block; + } +` + +// Using table display for this element, so that all dropdowns occupy the same width. +// (Bootstrap already sets them to occupy 100% of the width of the parent, i.e. the logical cell.) +const SettingsListWithAlign = styled(SettingsList)` + & > li { + display: table-row; + & > * { + display: table-cell; + } + & > label { + padding-right: 10px; + } + } +` + +const InlineFormControl = styled(FormControl)` + display: inline-block; + margin: 0 0.5em; + width: auto; +` + +const SettingsToggle = styled.button` + background: none; + border: none; + padding: 0; + & span.fa { + line-height: 150%; + } +` + +/** + * A label followed by a dropdown control. + */ +const Select = ({ children, Control = FormControl, label, name }) => ( + // is kept outside of