-
Notifications
You must be signed in to change notification settings - Fork 51
/
ui.js
415 lines (387 loc) · 14.7 KB
/
ui.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
import { createAction } from 'redux-actions'
import { matchPath } from 'react-router'
import { push } from 'connected-react-router'
import coreUtils from '@opentripplanner/core-utils'
import {
getConfigLocales,
getDefaultLocale,
getMatchingLocaleString,
loadLocaleData
} from '../util/i18n'
import { getModesForActiveAgencyFilter, getUiUrlParams } from '../util/state'
import { getPathFromParts } from '../util/ui'
import { clearActiveSearch, parseUrlQueryString, setActiveSearch } from './form'
import { clearLocation } from './map'
import { findRoute, setUrlSearch } from './api'
import { setActiveItinerary } from './narrative'
import { setMapCenter, setMapZoom, setRouterId } from './config'
const updateLocale = createAction('UPDATE_LOCALE')
// UI state enums
export const MainPanelContent = {
ROUTE_VIEWER: 1,
STOP_VIEWER: 2
}
export const MobileScreens = {
RESULTS_SUMMARY: 8,
SEARCH_FORM: 3,
SET_DATETIME: 7,
SET_FROM_LOCATION: 4,
SET_INITIAL_LOCATION: 2,
SET_OPTIONS: 6,
SET_TO_LOCATION: 5,
WELCOME_SCREEN: 1
}
/**
* Enum to describe the layout of the itinerary view
* (currently only used in batch results).
*/
export const ItineraryView = {
/** 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 setPanel = createAction('SET_MAIN_PANEL_CONTENT')
export const setMobileScreen = createAction('SET_MOBILE_SCREEN')
export const clearPanel = createAction('CLEAR_MAIN_PANEL')
const viewStop = createAction('SET_VIEWED_STOP')
export const setHoveredStop = createAction('SET_HOVERED_STOP')
export const setViewedTrip = createAction('SET_VIEWED_TRIP')
const viewRoute = createAction('SET_VIEWED_ROUTE')
export const unfocusRoute = createAction('UNFOCUS_ROUTE')
export const toggleAutoRefresh = createAction('TOGGLE_AUTO_REFRESH')
const setPreviousItineraryView = createAction('SET_PREVIOUS_ITINERARY_VIEW')
// This code-less action calls the reducer code
// and thus resets the session timeout.
export const resetSessionTimeout = createAction('RESET_SESSION_TIMEOUT')
/**
* Wrapper function for history#push (or, if specified, replace, etc.)
* that preserves the current search or, if
* replaceSearch is provided (including an empty string), replaces the search
* when routing to a new URL path.
* @param {[type]} url path to route to
* @param {string} replaceSearch optional search string to replace current one
* @param {func} routingMethod the connected-react-router method to execute (defaults to push).
*/
export function routeTo(url, replaceSearch, routingMethod = push) {
return function (dispatch, getState) {
// Get search to preserve when routing to new path.
const { router } = getState()
const search = router ? router.location.search : window.location.search
let path = url
if (replaceSearch || replaceSearch === '') {
path = `${path}${replaceSearch}`
} else {
path = `${path}${search}`
}
dispatch(routingMethod(path))
}
}
export function setViewedRoute(payload) {
return function (dispatch, getState) {
dispatch(viewRoute(payload))
const path = getPathFromParts(
'route',
payload?.routeId,
// If a pattern is supplied, include pattern in path
payload?.patternId && 'pattern',
payload?.patternId
)
dispatch(routeTo(path))
}
}
/**
* Sets the main panel content according to the payload (one of the enum values
* of MainPanelContent) and routes the application to the correct path.
* @param {number} payload MainPanelContent value
*/
export function setMainPanelContent(payload) {
return function (dispatch, getState) {
const { otp, router } = getState()
if (otp.ui.mainPanelContent === payload) {
console.warn(
`Attempt to route from ${otp.ui.mainPanelContent} to ${payload}. Doing nothing`
)
// Do nothing if the panel is already set. This will guard against over
// enthusiastic routing and overwriting current/nested states.
return
}
dispatch(setPanel(payload))
switch (payload) {
case MainPanelContent.ROUTE_VIEWER:
dispatch(routeTo('/route'))
break
case MainPanelContent.STOP_VIEWER:
dispatch(routeTo('/stop'))
break
default:
// Clear route, stop, and trip viewer focus and route to root
dispatch(viewRoute(null))
dispatch(viewStop(null))
dispatch(setViewedTrip(null))
if (router.location.pathname !== '/') dispatch(routeTo('/'))
break
}
}
}
// Stop/Route/Trip Viewer actions
export function setViewedStop(payload) {
return function (dispatch, getState) {
dispatch(viewStop(payload))
// payload.stopId may be undefined, which is ok as will be ignored by getPathFromParts
const path = getPathFromParts('stop', payload?.stopId)
if (payload?.stopId) dispatch(routeTo(path))
}
}
/**
* Split the path id into its parts (according to specified delimiter). Parse
* numbers if detected.
*/
function idToParams(id, delimiter = ',') {
return id.split(delimiter).map((s) => (isNaN(s) ? s : +s))
}
/**
* Checks URL and redirects app to appropriate content (e.g., viewed
* route or stop).
*/
export function matchContentToUrl(location) {
// eslint-disable-next-line complexity
return function (dispatch, getState) {
const state = getState()
// This is a bit of a hack to make up for the fact that react-router does
// not always provide the match params as expected.
// https://github.com/ReactTraining/react-router/issues/5870#issuecomment-394194338
const root = location.pathname.split('/')[1]
const match = matchPath(location.pathname, {
exact: false,
path: `/${root}/:id`,
strict: false
})
const id = match?.params?.id
switch (root) {
case 'route':
if (id) {
// This is a bit of a hack to check if the route details have been grabbed
// bikesAllowed will only be populated if route details are present
// Moving away from manual requests should help resolve this
if (!state.otp.transitIndex?.routes?.[id]?.bikesAllowed) {
dispatch(findRoute({ routeId: id }))
}
// Check for pattern "submatch"
const subMatch = matchPath(location.pathname, {
exact: true,
path: `/${root}/:id/pattern/:patternId`,
strict: false
})
const patternId = subMatch?.params?.patternId
// patternId may be undefined, which is OK as the route will still be routed
dispatch(setViewedRoute({ patternId, routeId: id }))
} else {
dispatch(setViewedRoute(null))
}
dispatch(setMainPanelContent(MainPanelContent.ROUTE_VIEWER))
break
case 'stop':
if (id) {
dispatch(setViewedStop({ stopId: id }))
} else {
dispatch(setViewedStop(null))
dispatch(setMainPanelContent(MainPanelContent.STOP_VIEWER))
}
break
case 'start':
case '@': {
// Parse comma separated params (ensuring numbers are parsed correctly).
let [lat, lon, zoom, routerId] = id ? idToParams(id) : []
if (!lat || !lon) {
// Attempt to parse path if lat/lon not found. (Legacy UI otp.js used
// slashes in the pathname to specify lat, lon, etc.)
// prettier-ignore
[,, lat, lon, zoom, routerId] = idToParams(location.pathname, '/')
}
console.log(lat, lon, zoom, routerId)
// Update map location/zoom and optionally override router ID.
if (+lat && +lon) dispatch(setMapCenter({ lat, lon }))
if (+zoom) dispatch(setMapZoom({ zoom }))
// If router ID is provided, override the default routerId.
if (routerId) dispatch(setRouterId(routerId))
dispatch(setMainPanelContent(null))
break
}
// For any other route path, just revert to default panel.
default:
dispatch(setMainPanelContent(null))
break
}
}
}
/**
* Event listener for responsive webapp that handles a back button press and
* sets the active search and itinerary according to the URL query params.
*/
export function handleBackButtonPress(e) {
return function (dispatch, getState) {
const state = getState()
const { activeSearchId } = state.otp
const uiUrlParams = getUiUrlParams(state)
// Get new search ID from URL after back button pressed.
// console.log('back button pressed', e)
const urlParams = coreUtils.query.getUrlParams()
const previousSearchId = urlParams.ui_activeSearch
const previousItinIndex = +urlParams.ui_activeItinerary || 0
const previousSearch = state.otp.searches[previousSearchId]
if (previousSearch) {
// If back button pressed and active search has changed, set search to
// previous search ID.
if (activeSearchId !== previousSearchId) {
dispatch(setActiveSearch(previousSearchId))
} else if (uiUrlParams.ui_activeItinerary !== previousItinIndex) {
// Active itinerary index has changed.
dispatch(setActiveItinerary({ index: previousItinIndex }))
}
} else {
// The back button was pressed, but there was no corresponding search
// found for the previous search ID. Derive search from URL params.
if (!previousSearchId && activeSearchId) {
// There is no search ID. Clear active search and from/to
dispatch(clearActiveSearch())
dispatch(clearLocation({ type: 'from' }))
dispatch(clearLocation({ type: 'to' }))
} else if (previousSearchId) {
console.warn(
`No search found in state history for search ID: ${previousSearchId}. Replanning...`
)
// Set query to the params found in the URL and perform routing query
// for search ID.
// Also, we don't want to update the URL here because that will funk with
// the browser history.
dispatch(parseUrlQueryString(urlParams))
}
}
}
}
/**
* 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.LIST
// 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.LIST) {
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.LIST
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))
}
}
/**
* Sets the locale to the specified value and loads the corresponding messages.
* If the specified locale is null, fall back to the defaultLocale
* set in the configuration.
* Also update the lang attribute on the root <html> element for accessibility.
*/
export function setLocale(locale) {
return async function (dispatch, getState) {
const { config } = getState().otp
const { loggedInUser } = getState().user
const { language: customMessages } = config
const configLocales = getConfigLocales(customMessages)
const effectiveLocale = locale || getDefaultLocale(config, loggedInUser)
const matchedLocale = getMatchingLocaleString(
effectiveLocale,
'en-US',
configLocales
)
const messages = await loadLocaleData(
matchedLocale,
customMessages,
configLocales
)
// Update the redux state, only with a matched locale
dispatch(updateLocale({ locale: matchedLocale, messages }))
// Update the lang attribute in the root <html> element.
// (The lang is the first portion of the locale.)
const lang = effectiveLocale.split('-')[0]
document.documentElement.lang = lang
}
}
const updateRouteViewerFilter = createAction('UPDATE_ROUTE_VIEWER_FILTER')
/**
* Updates the route viewer filter
* @param {*} filter Object which includes either agency, mode, and/or search
*/
export function setRouteViewerFilter(filter) {
return async function (dispatch, getState) {
dispatch(updateRouteViewerFilter(filter))
// If we're changing agency, and have a mode selected,
// ensure that the mode filter doesn't select non-existent modes!
const activeModeFilter = getState().otp.ui.routeViewer.filter.mode
if (
filter.agency &&
activeModeFilter &&
!getModesForActiveAgencyFilter(getState()).includes(
activeModeFilter.toUpperCase()
)
) {
// If invalid mode is selected, reset mode
dispatch(updateRouteViewerFilter({ mode: null }))
}
}
}
/**
* Start over by setting the initial URL and resetting (reloading) the page.
* Note: This code is slightly different from the code behind the "Start Over" menu/button
* in the sense that it preserves the initial URL with original query parameters.
* TODO: Make the code from "Start Over" reuse this function.
*/
export function startOverFromInitialUrl() {
return function (dispatch, getState) {
const { initialUrl } = getState().otp
window.location.replace(initialUrl)
window.location.reload()
}
}