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: () =>
- {/*
- 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) => (
-
- ))
- :
+
+)
+
+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 && }
+
diff --git a/lib/components/mobile/results-error.js b/lib/components/mobile/results-error.js
new file mode 100644
index 000000000..eb8728a1e
--- /dev/null
+++ b/lib/components/mobile/results-error.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import styled from 'styled-components'
+
+import ErrorMessage from '../form/error-message'
+
+import EditSearchButton from './edit-search-button'
+
+/**
+ * This component is used on mobile views to
+ * render an error message if no results are found.
+ */
+const ResultsError = ({ className, error }) => (
+