diff --git a/example.js b/example.js
index e3bb59688..33852b548 100644
--- a/example.js
+++ b/example.js
@@ -13,6 +13,7 @@ import createLogger from 'redux-logger'
// import OTP-RR components
import {
+ BatchResultsScreen,
BatchRoutingPanel,
BatchSearchScreen,
CallTakerControls,
@@ -21,6 +22,7 @@ import {
DefaultItinerary,
DefaultMainPanel,
FieldTripWindows,
+ MobileResultsScreen,
MobileSearchScreen,
ResponsiveWebapp,
createCallTakerReducer,
@@ -76,6 +78,7 @@ const TermsOfStorage = () => (
// - MainControls (optional)
// - MainPanel (required)
// - MapWindows (optional)
+// - MobileResultsScreen (required)
// - MobileSearchScreen (required)
// - ModeIcon (required)
// - TermsOfService (required if otpConfig.persistence.strategy === 'otp_middleware')
@@ -96,6 +99,9 @@ const components = {
>
: null,
+ MobileResultsScreen: isBatchRoutingEnabled
+ ? BatchResultsScreen
+ : MobileResultsScreen,
MobileSearchScreen: isBatchRoutingEnabled
? BatchSearchScreen
: MobileSearchScreen,
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/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/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 }) => (
+
+)
+
+// connect to the redux store
+const mapDispatchToProps = {
+ showMobileSearchScreen: uiActions.showMobileSearchScreen
+}
+
+export default connect(null, mapDispatchToProps)(EditSearchButton)
diff --git a/lib/components/mobile/main.js b/lib/components/mobile/main.js
index dc9eb2344..4027f295e 100644
--- a/lib/components/mobile/main.js
+++ b/lib/components/mobile/main.js
@@ -6,46 +6,56 @@ import MobileDateTimeScreen from './date-time-screen'
import MobileOptionsScreen from './options-screen'
import MobileLocationSearch from './location-search'
import MobileWelcomeScreen from './welcome-screen'
-import MobileResultsScreen from './results-screen'
import MobileStopViewer from './stop-viewer'
import MobileTripViewer from './trip-viewer'
import MobileRouteViewer from './route-viewer'
-import { MobileScreens, MainPanelContent, setMobileScreen } from '../../actions/ui'
+import * as uiActions from '../../actions/ui'
import { ComponentContext } from '../../util/contexts'
-import { getActiveItinerary } from '../../util/state'
+import { getActiveSearch } from '../../util/state'
+
+const { MainPanelContent, MobileScreens } = uiActions
class MobileMain extends Component {
static propTypes = {
+ activeSearch: PropTypes.object,
+ currentPosition: PropTypes.object,
currentQuery: PropTypes.object,
- map: PropTypes.element,
setMobileScreen: PropTypes.func,
- title: PropTypes.element,
uiState: PropTypes.object
}
static contextType = ComponentContext
componentDidUpdate (prevProps) {
+ const {
+ activeSearch,
+ currentPosition,
+ currentQuery,
+ setMobileScreen
+ } = this.props
+
// Check if we are in the welcome screen and both locations have been set OR
// auto-detect is denied and one location is set
if (
prevProps.uiState.mobileScreen === MobileScreens.WELCOME_SCREEN && (
- (this.props.currentQuery.from && this.props.currentQuery.to) ||
- (!this.props.currentPosition.coords && (this.props.currentQuery.from || this.props.currentQuery.to))
+ (currentQuery.from && currentQuery.to) ||
+ (!currentPosition.coords && (currentQuery.from || currentQuery.to))
)
) {
// If so, advance to main search screen
- this.props.setMobileScreen(MobileScreens.SEARCH_FORM)
+ setMobileScreen(MobileScreens.SEARCH_FORM)
}
- if (!prevProps.activeItinerary && this.props.activeItinerary) {
- this.props.setMobileScreen(MobileScreens.RESULTS_SUMMARY)
+ // Display the results screen if an active search exists
+ // (i.e. results are being fetched, or returned, or if there are errors).
+ if (!prevProps.activeSearch && activeSearch) {
+ setMobileScreen(MobileScreens.RESULTS_SUMMARY)
}
}
render () {
- const { MobileSearchScreen } = this.context
+ const { MobileResultsScreen, MobileSearchScreen } = this.context
const { uiState } = this.props
// check for route viewer
@@ -111,17 +121,19 @@ class MobileMain extends Component {
// connect to the redux store
const mapStateToProps = (state, ownProps) => {
+ const { config, currentQuery, location, ui: uiState } = state.otp
+ const activeSearch = getActiveSearch(state.otp)
return {
- config: state.otp.config,
- uiState: state.otp.ui,
- currentQuery: state.otp.currentQuery,
- currentPosition: state.otp.location.currentPosition,
- activeItinerary: getActiveItinerary(state.otp)
+ activeSearch,
+ config,
+ currentPosition: location.currentPosition,
+ currentQuery,
+ uiState
}
}
const mapDispatchToProps = {
- setMobileScreen
+ setMobileScreen: uiActions.setMobileScreen
}
export default connect(mapStateToProps, mapDispatchToProps)(MobileMain)
diff --git a/lib/components/mobile/mobile.css b/lib/components/mobile/mobile.css
index 1e6475c6e..eaa89f452 100644
--- a/lib/components/mobile/mobile.css
+++ b/lib/components/mobile/mobile.css
@@ -115,7 +115,8 @@
left: 0;
right: 0;
height: 216px;
- z-index: 99999999;
+ /* Must appear under the 'hamburger' dropdown which has z-index of 1000. */
+ z-index: 999;
box-shadow: 3px 0px 12px #00000052;
}
@@ -159,7 +160,6 @@
text-align: center;
font-size: 20px;
font-weight: 500;
- padding-top: 5px;
}
.otp.mobile .mobile-narrative-container {
@@ -213,7 +213,6 @@
.otp.mobile .results-error-message {
position: fixed;
- top: 300px;
left: 0;
right: 0;
bottom: 0;
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 }) => (
+
+
+
+
+ Back to Search
+
+
+
+)
+
+ResultsError.propTypes = {
+ error: PropTypes.object
+}
+
+const StyledResultsError = styled(ResultsError)`
+ top: 300px;
+`
+
+export default StyledResultsError
diff --git a/lib/components/mobile/results-header.js b/lib/components/mobile/results-header.js
new file mode 100644
index 000000000..d0aa51c4b
--- /dev/null
+++ b/lib/components/mobile/results-header.js
@@ -0,0 +1,112 @@
+import LocationIcon from '@opentripplanner/location-icon'
+import PropTypes from 'prop-types'
+import React, { Component } from 'react'
+import { Col, Row } from 'react-bootstrap'
+import { connect } from 'react-redux'
+import styled from 'styled-components'
+
+import {
+ getActiveItineraries,
+ getActiveSearch,
+ getResponsesWithErrors
+} from '../../util/state'
+
+import EditSearchButton from './edit-search-button'
+import MobileNavigationBar from './navigation-bar'
+
+const LocationContainer = styled.div`
+ font-weight: 300;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`
+
+const LocationSummaryContainer = styled.div`
+ height: 50px;
+ left: 0;
+ padding-right: 10px;
+ position: fixed;
+ right: 0;
+ top: 50px;
+`
+
+const LocationsSummaryColFromTo = styled(Col)`
+ font-size: 1.1em;
+ line-height: 1.2em;
+`
+
+const LocationsSummaryRow = styled(Row)`
+ padding: 4px 8px;
+`
+
+const StyledLocationIcon = styled(LocationIcon)`
+ margin: 3px;
+`
+
+/**
+ * This component renders the results header and an error message
+ * if no itinerary was found.
+ */
+class ResultsHeader extends Component {
+ static propTypes = {
+ errors: PropTypes.array,
+ query: PropTypes.object,
+ resultCount: PropTypes.number
+ }
+
+ render () {
+ const { errors, query, resultCount } = this.props
+ const hasNoResult = resultCount === 0 && errors.length > 0
+ const headerText = hasNoResult
+ ? 'No Trip Found'
+ : (resultCount
+ ? `We Found ${resultCount} Option${resultCount > 1 ? 's' : ''}`
+ : 'Waiting...'
+ )
+
+ return (
+ <>
+
+
+
+
+
+
+ { query.from ? query.from.name : '' }
+
+
+ { query.to ? query.to.name : '' }
+
+
+
+
+ Edit
+
+
+
+
+ >
+ )
+ }
+}
+
+// connect to the redux store
+
+const mapStateToProps = (state, ownProps) => {
+ const activeSearch = getActiveSearch(state.otp)
+ const {useRealtime} = state.otp
+ const response = !activeSearch
+ ? null
+ : useRealtime ? activeSearch.response : activeSearch.nonRealtimeResponse
+
+ const itineraries = getActiveItineraries(state.otp)
+ return {
+ errors: getResponsesWithErrors(state.otp),
+ query: state.otp.currentQuery,
+ resultCount: response
+ ? itineraries.length
+ : null
+ }
+}
+
+export default connect(mapStateToProps)(ResultsHeader)
diff --git a/lib/components/mobile/results-screen.js b/lib/components/mobile/results-screen.js
index 93bb9bb61..ece1501bc 100644
--- a/lib/components/mobile/results-screen.js
+++ b/lib/components/mobile/results-screen.js
@@ -1,21 +1,12 @@
import coreUtils from '@opentripplanner/core-utils'
-import LocationIcon from '@opentripplanner/location-icon'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
-import { Button, Col, Row } from 'react-bootstrap'
import { connect } from 'react-redux'
import styled from 'styled-components'
+import * as narrativeActions from '../../actions/narrative'
import Map from '../map/map'
-import ErrorMessage from '../form/error-message'
import ItineraryCarousel from '../narrative/itinerary-carousel'
-
-import MobileContainer from './container'
-import MobileNavigationBar from './navigation-bar'
-
-import { MobileScreens, setMobileScreen } from '../../actions/ui'
-import { setUseRealtimeResponse } from '../../actions/narrative'
-import { clearActiveSearch } from '../../actions/form'
import {
getActiveError,
getActiveItineraries,
@@ -23,41 +14,36 @@ import {
getRealtimeEffects
} from '../../util/state'
-const LocationContainer = styled.div`
- font-weight: 300;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`
-
-const LocationSummaryContainer = styled.div`
- height: 50px;
- left: 0;
- padding-right: 10px;
- position: fixed;
- right: 0;
- top: 50px;
-`
-
-const LocationsSummaryColFromTo = styled(Col)`
- font-size: 1.1em;
- line-height: 1.2em;
-`
-
-const LocationsSummaryRow = styled(Row)`
- padding: 4px 8px;
+import MobileContainer from './container'
+import ResultsError from './results-error'
+import ResultsHeader from './results-header'
+
+const OptionExpander = styled.button`
+ border: none;
+ bottom: ${props => props.expanded ? 'inherit' : '100px'};
+ outline: none;
+ padding-bottom: 3px;
+ display: block;
+ top: ${props => props.expanded ? '100px' : 'inherit'};
+ width: 100%;
`
-const StyledLocationIcon = styled(LocationIcon)`
- margin: 3px;
+const NarrativeContainer = styled.div`
+ background-color: white;
+ ${props => props.expanded
+ ? 'top: 140px; overflow-y: auto;'
+ : 'height: 80px; overflow-y: hidden;'}
`
class MobileResultsScreen extends Component {
static propTypes = {
activeItineraryIndex: PropTypes.number,
+ activeLeg: PropTypes.number,
+ error: PropTypes.object,
query: PropTypes.object,
+ realtimeEffects: PropTypes.object,
resultCount: PropTypes.number,
- setMobileScreen: PropTypes.func
+ useRealtime: PropTypes.bool
}
constructor () {
@@ -86,11 +72,6 @@ class MobileResultsScreen extends Component {
this.refs['narrative-container'].scrollTop = 0
}
- _editSearchClicked = () => {
- this.props.clearActiveSearch()
- this.props.setMobileScreen(MobileScreens.SEARCH_FORM)
- }
-
_optionClicked = () => {
this._setExpanded(!this.state.expanded)
}
@@ -107,8 +88,8 @@ class MobileResultsScreen extends Component {
for (let i = 0; i < resultCount; i++) {
dots.push(
)
}
@@ -118,116 +99,56 @@ class MobileResultsScreen extends Component {
)
}
- renderError = () => {
- const { error } = this.props
-
- return (
-
-
- {this.renderLocationsSummary()}
-
-
-
-
-
-
-
-
- )
- }
-
- renderLocationsSummary = () => {
- const { query } = this.props
-
- return (
-
-
-
-
- { query.from ? query.from.name : '' }
-
-
- { query.to ? query.to.name : '' }
-
-
-
-
-
-
-
- )
- }
-
render () {
const {
activeItineraryIndex,
error,
realtimeEffects,
- resultCount,
useRealtime
} = this.props
const { expanded } = this.state
- const narrativeContainerStyle = expanded
- ? { top: 140, overflowY: 'auto' }
- : { height: 80, overflowY: 'hidden' }
-
- // Ensure that narrative covers map.
- narrativeContainerStyle.backgroundColor = 'white'
-
- let headerAction = null
const showRealtimeAnnotation = realtimeEffects.isAffectedByRealtimeData && (
realtimeEffects.exceedsThreshold ||
realtimeEffects.routesDiffer ||
!useRealtime
)
- if (error) {
- return this.renderError()
- }
-
return (
- 1 ? 's' : ''}`
- : 'Waiting...'
- }
- headerAction={headerAction}
- />
- {this.renderLocationsSummary()}
-
-
+
+
-
-
- Option {activeItineraryIndex + 1}
-
-
-
-
-
-
- {this.renderDots()}
+ {error
+ ?
+ : (
+ <>
+
+ Option {activeItineraryIndex + 1}
+
+
+
+
+
+
+ {this.renderDots()}
+ >
+ )
+ }
)
}
@@ -245,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/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js
index 8cc02b610..b241ac753 100644
--- a/lib/components/narrative/narrative-itineraries.js
+++ b/lib/components/narrative/narrative-itineraries.js
@@ -12,6 +12,7 @@ import {
setVisibleItinerary,
updateItineraryFilter
} from '../../actions/narrative'
+import * as uiActions from '../../actions/ui'
import Icon from '../narrative/icon'
import { ComponentContext } from '../../util/contexts'
import {
@@ -23,6 +24,8 @@ import {
import SaveTripButton from './save-trip-button'
+const { ItineraryView } = uiActions
+
// TODO: move to utils?
function humanReadableMode (modeStr) {
if (!modeStr) return 'N/A'
@@ -37,23 +40,48 @@ 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)
+ }
+ }
+
+ _isShowingDetails = () => {
+ const { itineraryView } = this.props
+ return itineraryView === ItineraryView.FULL ||
+ itineraryView === ItineraryView.LEG ||
+ itineraryView === ItineraryView.LEG_HIDDEN
+ }
_toggleDetailedItinerary = () => {
- this.setState({showDetails: !this.state.showDetails})
+ const { setActiveLeg, setItineraryView } = this.props
+ const newView = this._isShowingDetails() ? ItineraryView.LIST : ItineraryView.FULL
+ setItineraryView(newView)
+ // Reset the active leg.
+ setActiveLeg(null, null)
}
_onSortChange = evt => {
@@ -94,19 +122,27 @@ class NarrativeItineraries extends Component {
render () {
const {
activeItinerary,
+ activeLeg,
activeSearch,
+ activeStep,
containerStyle,
errors,
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 ||
@@ -116,6 +152,7 @@ class NarrativeItineraries extends Component {
const resultText = pending
? 'Finding your options...'
: `${itineraries.length} itineraries found.`
+
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 (
@@ -226,17 +270,20 @@ const mapStateToProps = (state, ownProps) => {
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,
+ errors: getResponsesWithErrors(state.otp),
+ itineraries,
+ itineraryView: urlParams.ui_itineraryView || ItineraryView.DEFAULT,
modes,
+ pending,
+ realtimeEffects,
sort,
timeFormat: coreUtils.time.getTimeFormat(state.otp.config),
useRealtime,
@@ -258,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/index.js b/lib/index.js
index 1bf19fee9..3f1d1862c 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -30,6 +30,7 @@ import TripTools from './components/narrative/trip-tools'
import LineItinerary from './components/narrative/line-itin/line-itinerary'
import MobileMain from './components/mobile/main'
+import MobileResultsScreen from './components/mobile/results-screen'
import MobileSearchScreen from './components/mobile/search-screen'
import NavLoginButton from './components/user/nav-login-button'
@@ -47,6 +48,7 @@ import DesktopNav from './components/app/desktop-nav'
import DefaultMainPanel from './components/app/default-main-panel'
import BatchRoutingPanel from './components/app/batch-routing-panel'
+import BatchResultsScreen from './components/mobile/batch-results-screen'
import BatchSearchScreen from './components/mobile/batch-search-screen'
import { setAutoPlan, setMapCenter } from './actions/config'
@@ -97,6 +99,7 @@ export {
// mobile components
MobileMain,
+ MobileResultsScreen,
MobileSearchScreen,
// viewer components
@@ -117,6 +120,7 @@ export {
DefaultMainPanel,
// batch routing components
+ BatchResultsScreen,
BatchRoutingPanel,
BatchSearchScreen,
diff --git a/lib/reducers/create-otp-reducer.js b/lib/reducers/create-otp-reducer.js
index 863b94bbb..1fadd42c0 100644
--- a/lib/reducers/create-otp-reducer.js
+++ b/lib/reducers/create-otp-reducer.js
@@ -945,6 +945,8 @@ function createOtpReducer (config) {
return update(state,
{ filter: { $set: action.payload } }
)
+ case 'SET_PREVIOUS_ITINERARY_VIEW':
+ return update(state, { ui: { previousItineraryView: { $set: action.payload } } })
default:
return state
}
diff --git a/lib/util/state.js b/lib/util/state.js
index a3459b805..4caab3f7f 100644
--- a/lib/util/state.js
+++ b/lib/util/state.js
@@ -99,7 +99,6 @@ export function getActiveItineraries (otpState) {
})
return hasCar
default:
- console.warn(`Filter (${filter}) not supported`)
return true
}
})