diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index d2191a848..15dffceb6 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -23,11 +23,17 @@ jobs: uses: bahmutov/npm-install@v1 - name: Copy example config run: cp example-config.yml config.yml - # Actual lint step temporarily removed to allow for package release + - name: Lint code + # Move everything from latest commit back to staged + run: git reset --soft HEAD^ && yarn lint + # For our info, lint all files but don't mark them as failure + # TODO: remove this once project is typescripted - name: Lint all code (ignoring errors) run: yarn lint-all || true + - name: Run type check + run: yarn typecheck - name: Run tests - run: yarn jest + run: yarn unit - name: Build example project run: yarn build - name: Run a11y tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..334230c95 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Open Trip Planner - React Redux Changelog + +## [3.0.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.0.0) (2021-04-13) + +- Add mobile batch results screen +- User Account Settings +- Add more buttons to favorites management +- Re-organize user settings +- Add field trip module + +## [3.1.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.1.0) (2021-07-07) + +- Add reverse directions button +- Add mailables submodule to calltaker module +- Improve map performance + +## [3.2.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.2.0) (2021-07-15) + +- Add GTFS-RT vehicle overlay +- Add user language settings +- Support overriding strings + +## [3.3.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.3.0) (2021-07-22) + +- Field trip module + - Add save button + - Add delete button + - Allow configuration of capacity + +## [3.4.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.4.0) (2021-08-18) + +- Add automatic a11y compliance tests +- Adjust colors to conform with [Web Content Accessibility Guidelines](https://www.w3.org/WAI/standards-guidelines/wcag/). + +## [3.5.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.5.0) (2021-10-05) + +- Add Traveler Tools Menu +- Add Route Details Viewer +- Move route viewer to title bar +- Add support for displaying realtime vehicle positions +- Support filtering routes by agency or mode + +## [3.6.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.6.0) (2021-10-21) + +- Further replace colors with accessible ones +- Add nearby amenities panel to stop viewer +- Add support for Local Places Index + +## [3.7.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.7.0) (2021-11-12) + +- Fix reported crashes +- Zoom to stop when opening stop viewer +- Display search results based on geolocation +- New stop markers on map at high zoom levels +- Add accessibility labels to legs and itineraries when accessible routing is enabled +- Support showing multiple fare prices + +## [3.8.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v3.8.0) (2021-12-16) + +- Fix crashes reported to us +- Improve screen reader experience +- Add user setting for planning accessible trips by default +- Support internationalization of most components + +## [4.0.0](https://github.com/opentripplanner/otp-react-redux/releases/tag/v4.0.0) (2022-02-10) + +- Migrate to webpack-based build system +- Enhance rendering of flex routes +- Clean up and modernize call taker interface +- Internationalize and modernize codebase of `trip-form` component +- Internationalize `location-field` component +- GTFS Flex support in stop viewer, route viewer, and trip viewer +- Improve styling of batch itinerary viewer +- Support showing recently searched and saved places in main panel diff --git a/README.md b/README.md index 2f6b8f2f7..59f4e7857 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ yarn start Should you want to maintain multiple configuration files, OTP-RR can be made to use a custom config file by using environment variables. Other environment variables also exist. `CUSTOM_CSS` can be used to point to a css file to inject, and `JS_CONFIG` can be used to point to a `config.js` file to override the one shipped with OTP-RR. ```bash -yarn start --env.YAML_CONFIG=/absolute/path/to/config.yml +env YAML_CONFIG=/absolute/path/to/config.yml yarn start ``` ## Deploying the UI @@ -31,7 +31,7 @@ Build the js/css bundle by running `yarn build`. The build will appear in the `d The same environment variables which affect the behavior of `yarn start` also affect `yarn build`. Running the following command builds OTP-RR with customized js and css: ```bash -yarn build --env.JS_CONFIG=my-custom-js.js env.CUSTOM_CSS=my-custom-css.css +env JS_CONFIG=my-custom-js.js CUSTOM_CSS=my-custom-css.css yarn build ``` ## Library Documentation diff --git a/__tests__/actions/__snapshots__/api.js.snap b/__tests__/actions/__snapshots__/api.js.snap index 226cc8217..af787deaa 100644 --- a/__tests__/actions/__snapshots__/api.js.snap +++ b/__tests__/actions/__snapshots__/api.js.snap @@ -23,7 +23,7 @@ Array [ Array [ Object { "payload": Object { - "requestId": "abcd1238", + "requestId": "abcd1237", "response": Object { "fake": "response", }, @@ -32,17 +32,6 @@ Array [ "type": "ROUTING_RESPONSE", }, ], - Array [ - Object { - "payload": Object { - "error": [TypeError: Cannot read properties of undefined (reading 'trackRecent')], - "requestId": "abcd1239", - "searchId": "abcd1234", - "url": "http://mock-host.com:80/api/plan?fromPlace=Origin%20%2812%2C34%29%3A%3A12%2C34&toPlace=Destination%20%2834%2C12%29%3A%3A34%2C12&mode=WALK%2CTRANSIT&ignoreRealtimeUpdates=false&batchId=abcd1234", - }, - "type": "ROUTING_ERROR", - }, - ], Array [ [Function], ], @@ -52,7 +41,7 @@ Array [ "activeItinerary": 0, "pending": 1, "routingType": "ITINERARY", - "searchId": "abcd1237", + "searchId": "abcd1236", "updateSearchInReducer": false, }, "type": "ROUTING_REQUEST", @@ -65,9 +54,9 @@ Array [ Object { "payload": Object { "error": [Error: Received error from server], - "requestId": "abcd1240", - "searchId": "abcd1237", - "url": "http://mock-host.com:80/api/plan?fromPlace=Origin%20%2812%2C34%29%3A%3A12%2C34&toPlace=Destination%20%2834%2C12%29%3A%3A34%2C12&mode=WALK%2CTRANSIT&ignoreRealtimeUpdates=false&batchId=abcd1237", + "requestId": "abcd1238", + "searchId": "abcd1236", + "url": "http://mock-host.com:80/api/plan?fromPlace=Origin%20%2812%2C34%29%3A%3A12%2C34&toPlace=Destination%20%2834%2C12%29%3A%3A34%2C12&mode=WALK%2CTRANSIT&ignoreRealtimeUpdates=false&batchId=abcd1236", }, "type": "ROUTING_ERROR", }, @@ -107,17 +96,6 @@ Array [ "type": "ROUTING_RESPONSE", }, ], - Array [ - Object { - "payload": Object { - "error": [TypeError: Cannot read properties of undefined (reading 'trackRecent')], - "requestId": "abcd1236", - "searchId": "abcd1234", - "url": "http://mock-host.com:80/api/plan?fromPlace=Origin%20%2812%2C34%29%3A%3A12%2C34&toPlace=Destination%20%2834%2C12%29%3A%3A34%2C12&mode=WALK%2CTRANSIT&ignoreRealtimeUpdates=false&batchId=abcd1234", - }, - "type": "ROUTING_ERROR", - }, - ], ] `; diff --git a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap index bb4159b10..dd3959953 100644 --- a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap +++ b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap @@ -24,7 +24,7 @@ exports[`components > viewers > stop viewer should render countdown times after > viewers > stop viewer should render countdown times after zoomToStop={[Function]} > viewers > stop viewer should render countdown times after className="sc-crrszt jgWhKL" > viewers > stop viewer should render countdown times after className="sc-dlfnuX jzpRQX" > viewers > stop viewer should render countdown times for st > viewers > stop viewer should render countdown times for st zoomToStop={[Function]} > viewers > stop viewer should render countdown times for st className="sc-crrszt jgWhKL" > viewers > stop viewer should render countdown times for st className="sc-dlfnuX jzpRQX" > viewers > stop viewer should render times after midnight w > viewers > stop viewer should render times after midnight w zoomToStop={[Function]} > viewers > stop viewer should render times after midnight w className="sc-crrszt jgWhKL" > viewers > stop viewer should render times after midnight w className="sc-dlfnuX jzpRQX" > viewers > stop viewer should render with OTP transit index > viewers > stop viewer should render with OTP transit index zoomToStop={[Function]} > viewers > stop viewer should render with OTP transit index className="sc-crrszt jgWhKL" > viewers > stop viewer should render with OTP transit index className="sc-dlfnuX jzpRQX" > viewers > stop viewer should render with TriMet transit in > viewers > stop viewer should render with TriMet transit in zoomToStop={[Function]} > viewers > stop viewer should render with TriMet transit in className="sc-crrszt jgWhKL" > viewers > stop viewer should render with TriMet transit in className="sc-dlfnuX jzpRQX" > viewers > stop viewer should render with initial stop id a > viewers > stop viewer should render with initial stop id a zoomToStop={[Function]} > reducers > create-user-reducer should be able to create the initial state 1`] = ` +Object { + "accessToken": null, + "itineraryExistence": null, + "lastPhoneSmsRequest": Object { + "number": null, + "status": null, + "timestamp": 1970-01-01T00:00:00.000Z, + }, + "localUser": Object { + "defaults": Object { + "bikeSpeed": 3.58, + "endTime": "09:00", + "ignoreRealtimeUpdates": false, + "intermediatePlaces": Array [], + "maxBikeDistance": 4828, + "maxBikeTime": 20, + "maxEScooterDistance": 4828, + "maxWalkDistance": 1207, + "maxWalkTime": 15, + "mode": "WALK,TRANSIT", + "numItineraries": 3, + "optimize": "QUICK", + "optimizeBike": "SAFE", + "otherThanPreferredRoutesPenalty": 900, + "routingType": "ITINERARY", + "showIntermediateStops": true, + "startTime": "07:00", + "walkSpeed": 1.34, + "watts": 250, + "wheelchair": false, + }, + "favoriteStops": Array [], + "recentPlaces": Array [], + "recentSearches": Array [], + "savedLocations": Array [], + "storeTripHistory": false, + }, + "loggedInUser": null, + "loggedInUserMonitoredTrips": null, + "loggedInUserTripRequests": null, + "pathBeforeSignIn": null, +} +`; diff --git a/__tests__/reducers/create-user-reducer.js b/__tests__/reducers/create-user-reducer.js new file mode 100644 index 000000000..b2de0f588 --- /dev/null +++ b/__tests__/reducers/create-user-reducer.js @@ -0,0 +1,11 @@ +import { getUserInitialState } from '../../lib/reducers/create-user-reducer' +import { restoreDateNowBehavior, setDefaultTestTime } from '../test-utils' + +describe('lib > reducers > create-user-reducer', () => { + afterEach(restoreDateNowBehavior) + + it('should be able to create the initial state', () => { + setDefaultTestTime() + expect(getUserInitialState({})).toMatchSnapshot() + }) +}) diff --git a/__tests__/test-utils/mock-data/empty-yml.js b/__tests__/test-utils/mock-data/empty-yml.js new file mode 100644 index 000000000..734ddcaf0 --- /dev/null +++ b/__tests__/test-utils/mock-data/empty-yml.js @@ -0,0 +1,2 @@ +// Empty file for use with jest that replaces YML file imports. +// (jest 26 or one of its loaders does not process comments in YML files correctly.) diff --git a/__tests__/test-utils/mock-data/store.js b/__tests__/test-utils/mock-data/store.js index 9b2661d58..6a896ba00 100644 --- a/__tests__/test-utils/mock-data/store.js +++ b/__tests__/test-utils/mock-data/store.js @@ -11,6 +11,7 @@ import React from 'react' import thunk from 'redux-thunk' import { getInitialState } from '../../../lib/reducers/create-otp-reducer' +import { getUserInitialState } from '../../../lib/reducers/create-user-reducer' Enzyme.configure({ adapter: new EnzymeReactAdapter() }) @@ -29,7 +30,8 @@ export function getMockInitialState() { } return clone({ otp: getInitialState(mockConfig), - router: connectRouter(history) + router: connectRouter(history), + user: getUserInitialState(mockConfig) }) } diff --git a/__tests__/util/user.js b/__tests__/util/user.js new file mode 100644 index 000000000..b1472139e --- /dev/null +++ b/__tests__/util/user.js @@ -0,0 +1,86 @@ +/* globals describe, expect, it */ + +import { convertToLegacyLocation, convertToPlace } from '../../lib/util/user' + +describe('util > user', () => { + describe('convertToPlace', () => { + const testCases = [ + { + expected: { + address: '123 Main street', + icon: 'home', + id: 'id123', + lat: 12, + lon: 34, + name: 'Home', + type: 'home' + }, + input: { + icon: 'home', + id: 'id123', + lat: 12, + lon: 34, + name: '123 Main street', + type: 'home' + } + }, + { + expected: { + address: '123 Main street', + icon: 'briefcase', + id: 'id123', + lat: 12, + lon: 34, + name: 'Work', + type: 'work' + }, + input: { + icon: 'some-icon', + id: 'id123', + lat: 12, + lon: 34, + name: '123 Main street', + type: 'work' + } + } + ] + + testCases.forEach((testCase) => { + it('should convert a localStorage location to memory', () => { + expect(convertToPlace(testCase.input)).toEqual(testCase.expected) + }) + }) + }) + + describe('convertToLegacyLocation', () => { + const testCases = [ + { + expected: { + address: undefined, + icon: 'home', + id: 'id123', + lat: 12, + lon: 34, + name: '123 Main street', + type: 'home' + }, + input: { + address: '123 Main street', + icon: 'home', + id: 'id123', + lat: 12, + lon: 34, + type: 'home' + } + } + ] + + testCases.forEach((testCase) => { + it('should convert a memory place to a localStorage location', () => { + expect(convertToLegacyLocation(testCase.input)).toEqual( + testCase.expected + ) + }) + }) + }) +}) diff --git a/craco.config.js b/craco.config.js index f66ccf0cf..e54f98412 100644 --- a/craco.config.js +++ b/craco.config.js @@ -31,10 +31,9 @@ module.exports = { /** * Webpack can be passed a few environment variables to override the default * files used to run this project. The environment variables are CUSTOM_CSS, - * HTML_FILE, YAML_CONFIG, and JS_CONFIG. They must each be passed in the - * format --env.*=/path/to/file. For example: + * HTML_FILE, YAML_CONFIG, and JS_CONFIG. For example: * - * yarn start --env.YAML_CONFIG=/absolute/path/to/config.yml + * env YAML_CONFIG=/absolute/path/to/config.yml yarn start */ webpack: { // eslint-disable-next-line complexity diff --git a/example-config.yml b/example-config.yml index a91baec1c..b303ddbc5 100644 --- a/example-config.yml +++ b/example-config.yml @@ -2,12 +2,7 @@ api: host: http://localhost path: /otp/routers/default port: 8001 - -# Support OTP-2 in parallel for certain requests -# api_v2: -# host: http://localhost -# path: /otp/routers/default -# port: 8002 +# v2: false # Add suggested locations to be shown as options in the form view. # locations: @@ -112,6 +107,7 @@ map: ### styles.segment_labels: styles attributes recognized by transitive.js. ### For examples of applicable style attributes, see ### https://github.com/conveyal/transitive.js/blob/master/stories/Transitive.stories.js#L47. + ### - disableFlexArc: optional parameter to disable rendering flex itinerary legs as an arc. # transitive: # labeledModes: # - BUS @@ -127,6 +123,7 @@ map: # color: "#FFE0D0" # font-family: Hind, sans-serif # font-size: 18px + # disableFlexArc: true # it is possible to leave out a geocoder config entirely. In that case only # GPS coordinates will be used when finding the origin/destination. @@ -338,6 +335,11 @@ itinerary: ### The nested structure should be the same as the language files under the i18n folder. ### You can also customize OTP error messages for itinerary searches based on OTP HTTP codes. ### A separate message can be set for each language or locale if necessary. +### Languages defined may be region-specific (e.g. en-US) or language-specific (e.g. es, kr) +### The LocaleSelector component, which provides a dropdown for the user to change their locale, will +### only be rendered if more than one valid language (all languages must have "name" defined) is included +### in the language config. + # language: # allLanguages # common: @@ -365,6 +367,29 @@ itinerary: # modes: "CAR_HAIL, CAR_RENT" # 480: # msg: No available transit routes or rideshare/carshare service at origin. +# name: English (US) +# +# fr: +# config: +# # App (hamburger) menu items +# menuItems: +# car-pool: Covoiturage +# give-us-feedback: Donnez votre avis +# log-my-commute: Mes trajets verts +# traveler-tools: Outils du voyageur +# name: French + +# ko: +# name: 한국인 + +# vi: +# name: Tiếng Việt + +# zh: +# name: 中国人 + +# es: +# name: Español ### Localization section to provide language/locale settings #localization: @@ -405,3 +430,16 @@ dateTime: # # departure and it is not entirely excluded from display # # (defaults to 4 days/345600s if unspecified). # timeRange: 345600 +# routeViewer: +# # Whether to render routes within flex zones of a route's patterns. If set to true, +# # routes will not be rendered within flex zones. +# hideRouteShapesWithinFlexZones: true + +# API key to make Mapillary API calls. These are used to show street imagery. +# Mapillary calls these "Client Tokens". They can be created at https://www.mapillary.com/dashboard/developers +# mapillary: +# key: + +### Setting to enable touch-friendly behavior +### e.g. on touch-screen kiosks that run a desktop OS. +# isTouchScreenOnDesktop: true diff --git a/i18n/en-US.yml b/i18n/en-US.yml index d68c5ee3c..1c355dccf 100644 --- a/i18n/en-US.yml +++ b/i18n/en-US.yml @@ -189,10 +189,10 @@ components: notifications: Notifications places: Favorite places terms: Terms - FavoritePlaceRow: + FavoritePlaceList: addAnotherPlace: Add another place - # deleteThisPlace and editThisPlace are aria/tooltip texts. - deleteThisPlace: Delete this place + description: "Add the places you frequent often to save time planning trips:" + # editThisPlace is a tooltip text. editThisPlace: Edit this place setAddressForPlaceType: "Set your {placeType} address" FavoritePlaceScreen: @@ -204,8 +204,6 @@ components: nameAlreadyUsed: You are already using this name for another place. Please enter a different name. placeNotFound: Place not found placeNotFoundDescription: Sorry, the requested place was not found. - FavoritePlacesList: - description: "Add the places you frequent often to save time planning trips:" FormNavigationButtons: ariaLabel: Form navigation ItinerarySummary: @@ -310,9 +308,11 @@ components: (code expires after 10 minutes). verify: Verify Place: + # deleteThisPlace is an aria/tooltip text. + deleteThisPlace: Delete this place enterAlert: > Enter origin/destination in the form (or set via map click) - and click the resulting marker to set as {type} location. + and click the resulting marker to set as {placeType} location. viewStop: View Stop PlaceEditor: genericLocationPlaceholder: Search for location @@ -330,12 +330,10 @@ components: itinerary: Itinerary toggleMap: Toggle Map RealtimeAnnotation: - delaysNotShownInResults: Your trip results are currently being affected by service delays. These delays do not factor into travel times shown below. delaysShownInResults: > Your trip results have been adjusted based on real-time information. Under normal conditions, this trip would take {normalDuration} using the following routes: {routes}. - ignoreServiceDelays: Ignore service delays serviceUpdate: Service update RealtimeStatusLabel: # Note to translator: In itinerary body, early or late is single-line @@ -374,10 +372,6 @@ components: agencyFilter: Agency Filter allAgencies: All Agencies allModes: All Modes # Note to translator: This text is width-constrained. - applyServiceDelays: Apply service delays - delaysNotShownInResults: > - Your trip results are currently being affected by service delays. - These delays do not factor into travel times shown below. delaysShownInResults: > Your trip results have been adjusted based on real-time information. Under normal conditions, this trip would take {normalDuration} @@ -385,7 +379,6 @@ components: details: " " # If the string is left blank, React-Intl renders the id findARoute: Find A Route header: Route Viewer - ignoreServiceDelays: Ignore service delays modeFilter: Mode Filter noFilteredRoutesFound: No routes match your filter! noRouteUrl: No route URL provided. @@ -439,6 +432,7 @@ components: # Used in both desktop and mobile StopViewer: displayStopId: "Stop ID: {stopId}" + flexStop: This is a flex stop. Vehicles will drop off and pick up passengers in this flexible zone by request. You may have to call ahead for service in this area. header: Stop Viewer loadingText: Loading Stop... noStopsFound: No stop times found for date. @@ -583,11 +577,18 @@ components: linkCopied: Copied reportIssue: Report Issue reportEmailSubject: Reporting an Issue with OpenTripPlanner - reportEmailTemplate: > - ***INSTRUCTIONS TO USER*** + reportEmailTemplate: |+ + *** INSTRUCTIONS TO USER *** This feature allows you to email a report to site administrators for review. - Please add any additional feedback for this trip under the 'Additional Comments' - section below and send using your regular email program." + Please fill out the prompts below and send using your regular email program. + + *** PLEASE COMPLETE THE FOLLOWING *** + + Issue encountered: + + Type of trip you wanted to take (ex. walk + transit, bike + transit, car + transit): + + *** TECHNICAL DETAILS *** # Used in both desktop and mobile TripViewer: accessible: Accessible @@ -601,10 +602,13 @@ components: confirmDeletion: You have recent searches and/or places stored. Disabling storage of recent places/searches will remove these items. Continue? favoriteStops: Favorite stops myPreferences: My preferences + mySavedPlaces: My saved places (manage) noFavoriteStops: No favorite stops recentPlaces: Recent places recentSearches: Recent searches + recentSearchSummary: "{mode} from {from} to {to}" rememberSearches: Remember recent searches/places? + stopId: "Stop ID: {stopId}" storageDisclaimer: > Any preferences, places, or settings you opt to save will be stored in the local storage of your browser. @@ -634,6 +638,68 @@ components: prompt: Where do you want to go? otpUi: + DateTimeSelector: + arrive: Arrive by + depart: Depart at + now: Leave now + queryParameters: + bikeSpeed: Bicycle Speed + # 1 mile = 100 "centimiles" + # There are exceptions in English for 1/10 mile , 1/4 mile, 1/2 mile etc. + distanceInMiles: > + {centimiles, select, + 10 {1/10 mile} + 25 {1/4 mile} + 50 {1/2 mile} + 75 {3/4 mile} + other {{miles, number, :: unit/mile unit-width-full-name}} + } + maxBikeDistance: Maximum Bike + maxBikeTime: Max Bike Time + maxEScooterDistance: Maximum E-scooter Distance + maxWalkDistance: Maximum Walk + maxWalkTime: Max Walk Time + optimizeBikeFlat: Flat Trip + optimizeBikeFriendly: Bike-Friendly Trip + optimizeBikeSpeed: Speed + optimizeFor: Optimize for + optimizeQuick: Speed + optimizeTransfers: Fewest Transfers + speedInMilesPerHour: "{mph} MPH" # Original units were all caps. + walkSpeed: Walk Speed + watts: E-scooter Power + watts125kidsHoverboard: Kid's hoverboard (6 mph) + watts1500powerfulEscooter: Powerful E-scooter (24 mph) + watts250entryLevelEscooter: Entry-level scooter (11 mph) + watts500robustEscooter: Robust E-scooter (18 mph) + # Note to translator: This text is width-constrained. + wheelchair: Prefer Wheelchair Accessible Routes + LocationField: + beingTypingPrompt: Begin typing to search for locations + clearLocation: Clear location + currentLocationUnavailable: Current location not available ({error}) + fetchingLocation: Fetching location... + geocoderUnreachable: Could not reach geocoder{error, select, undefined {} other { ({error})}} + myPlaces: My Places + nearby: Nearby Stops + noResultsFound: > + No results found for {input, select, + null {your query} + other {{input}} + } + other: Other + recentlySearched: Recently Searched + stations: Stations + stops: Stops + useCurrentLocation: Use Current Location + SettingsSelectorPanel: + bikeOnly: Bike Only + escooterOnly: E-scooter Only + takeTransit: Take Transit + travelPreferences: Travel Preferences + use: Use # as in "Use bus, train, subway". + useCompanies: Use companies # as in :"Use companies: Spin, Lime, Bolt" + walkOnly: Walk Only TripDetails: calories: "Calories Burned: {calories, number, ::.}" caloriesDescription: > @@ -718,6 +784,8 @@ common: itineraryDescriptions: calories: "{calories, number} Cal" noItineraryToDisplay: No itinerary to display. + # Note to translator: noTransitFareProvided is width-constrained. + noTransitFareProvided: No fare info transfers: "{transfers, plural, =0 {} one {# transfer} other {# transfers}}" # Note to translator: the strings below are used in sentences such as: @@ -735,9 +803,10 @@ common: # The original OTP mode id is CABLE_CAR. Lowercase makes it cable_car. cable_car: Cable Car car: Car - car_park: car park + car_park: Park and Ride drive: Drive ferry: Ferry + flex: Flexible Routes funicular: Funicular gondola: Gondola micromobility: E-Scooter diff --git a/i18n/es.yml b/i18n/es.yml new file mode 100644 index 000000000..061b63be9 --- /dev/null +++ b/i18n/es.yml @@ -0,0 +1,25 @@ +_id: es +_name: Spanish + +# This file contains localized strings (a.k.a. messages) for the language indicated above: +# - Messages are organized in various categories and sub-categories. +# - A component or JS module can use messages from one or more categories. +# - In the code, messages are retrieved using an ID that is simply the path to the message. +# Use the dot '.' to separate categories and sub-categories in the path. +# For instance, for the message defined in YML below: +# common +# modes +# subway: Metro# +# then use the snippet below with the corresponding message id: +# // renders "Metro". +# +# It is important that message ids in the code be consistent with +# the categories in this file. Below are some general guidelines: +# - For starters, there are an 'actions', 'components' and 'common' +# categories. Additional categories may be added as needed. +# - Each sub-category under 'components' denotes a component and +# should contain messages that are used only by that component (e.g. button captions). +# - In contrast, some strings are common to multiple components, +# so it makes sense to group them by theme (e.g. accessModes) under the 'common' category. + +# Messages that are generated from actions \ No newline at end of file diff --git a/i18n/fr-FR.yml b/i18n/fr.yml similarity index 91% rename from i18n/fr-FR.yml rename to i18n/fr.yml index 06a99cb15..984fb9bdf 100644 --- a/i18n/fr-FR.yml +++ b/i18n/fr.yml @@ -1,4 +1,4 @@ -_id: fr-FR +_id: fr _name: Exemple de traduction pour OTP-react-redux en français ! # Noteworthy items for translating into French: @@ -177,10 +177,10 @@ components: notifications: Notifications places: Lieux favoris terms: Conditions d'utilisation - FavoritePlaceRow: + FavoritePlaceList: addAnotherPlace: Ajouter un autre lieu - # deleteThisPlace and editThisPlace are aria/tooltip texts. - deleteThisPlace: Supprimer ce lieu + description: "Ajoutez les lieux que vous fréquentez souvent pour faciliter vos recherches de trajets :" + # editThisPlace is a tooltip text. editThisPlace: Modifier ce lieu setAddressForPlaceType: Entrez l'adresse de votre {placeType} FavoritePlaceScreen: @@ -192,8 +192,6 @@ components: nameAlreadyUsed: Ce nom est déjà utilisé avec un autre lieu. Veuillez saisir un nom différent. placeNotFound: Lieu introuvable placeNotFoundDescription: Le lieu recherché est introuvable. - FavoritePlacesList: - description: "Ajoutez les lieux que vous fréquentez souvent pour faciliter vos recherches de trajets :" FormNavigationButtons: ariaLabel: Navigation du formulaire ItinerarySummary: @@ -276,8 +274,8 @@ components: other {Étendre} } tous les départs pour la ligne {routeName} departure: Départ - routeName: "{routeName} vers {headsign}" - routeShort: "Vers {headsign}" + routeName: "{routeName} Direction {headsign}" + routeShort: "Direction {headsign}" status: État PhoneNumberEditor: changeNumber: Changer de numéro @@ -297,10 +295,12 @@ components: Veuillez taper ce code ci-dessous (le code expire après 10 minutes). verify: Verifier Place: + # deleteThisPlace is an aria/tooltip text. + deleteThisPlace: Supprimer ce lieu enterAlert: > - Entrez votre point de départ/destination dans le formulaire (ou cliquez sur la carte) - puis cliquez sur le marqueur qui apparaît pour définir le type d'emplacement ({type}). - viewStop: Voir l'arrêt + Entrez votre point de départ/destination dans le formulaire (ou cliquez sur la carte), + puis cliquez sur le marqueur qui apparaît afin de définir votre {placeType}. + viewStop: Voir cet arrêt PlaceEditor: genericLocationPlaceholder: Adresse du lieu locationPlaceholder: Adresse de votre {placeName} @@ -317,11 +317,8 @@ components: itinerary: Votre trajet toggleMap: Afficher/masquer la carte RealtimeAnnotation: - delaysNotShownInResults: "Vos trajets recherchés sont perturbés par des retards. - Ces retards ne sont pas pris en compte dans les temps de trajet ci-dessous." delaysShownInResults: "Vos trajets recherchés ont été mis à jour avec les conditions en temps réel. En temps normal, ce trajet prendrait {normalDuration} en empruntant les lignes: {routes}." - ignoreServiceDelays: Ignorer les retards serviceUpdate: Information sur le service RealtimeStatusLabel: # Note to translator: In itinerary body, early or late is single-line @@ -338,7 +335,7 @@ components: showExtraStops: "Afficher {count} arrêts à proximité" RelatedStopsPanel: relatedStops: Arrêts à proximité - viewDetails: Afficher les détails + viewDetails: Détails noArrivalFound: Aucun passage prévu ResultsError: backToSearch: Retour à la recherche @@ -360,17 +357,12 @@ components: agencyFilter: Filtrer les transporteurs allAgencies: Tous transporteurs allModes: Tous modes # Note to translator: This text is width-constrained. - applyServiceDelays: Appliquer les retards - delaysNotShownInResults: > - Vos trajets recherchés sont perturbés par des retards. - Ces retards ne sont pas pris en compte dans les temps de trajet ci-dessous. delaysShownInResults: > Vos trajets recherchés ont été mis à jour avec les conditions en temps réel. En temps normal, ce trajet prendrait {normalDuration} en empruntant les lignes: {routes}. details: " " # If the string is left blank, React-Intl renders the id findARoute: Chercher une ligne header: Index des lignes - ignoreServiceDelays: Ignorer les retards modeFilter: Filtre pour les modes noFilteredRoutesFound: Aucune ligne ne correspond à vos critères noRouteUrl: Aucun lien fourni pour cette ligne. @@ -413,7 +405,7 @@ components: StopScheduleTable: block: Bloc departure: Départ - destination: Destination + destination: Direction route: Ligne StopTimeCell: imminentArrival: > @@ -423,16 +415,17 @@ components: } # Used in both desktop and mobile StopViewer: - displayStopId: "Arrêt n°{stopId}" + displayStopId: "Arrêt n° {stopId}" + flexStop: Cet arrêt fait partie d'une zone 'Flex' et est desservi à la demande. Une réservation préalable peut être exigée pour obtenir la desserte. header: Info arrêt loadingText: Chargement de l'arrêt... - noStopsFound: Aucun passage n'a été trouvé pour cette date. - planTrip: "Planifer un trajet :" + noStopsFound: Aucun horaire pour la date choisie. + planTrip: "Faire un trajet :" timezoneWarning: "Les horaires sont affichés dans le fuseau {timezoneCode}." viewTypeBtnText: > {scheduleView, select, - true {Afficher les prochains passages} - other {Afficher les horaires} + true {Prochains passages} + other {Horaires} } zoomToStop: Zoomer sur l'arrêt @@ -568,11 +561,18 @@ components: linkCopied: Copié reportIssue: Un problème ? # "Signaler un problème" does not fit. reportEmailSubject: Signaler un problème avec OpenTripPlanner - reportEmailTemplate: > - *** À L'ATTENTION DE L'UTILISATEUR *** - Vous pouvez communiquer votre problème par e-mail et en détail aux administrateurs de ce site. - Veuillez ajouter toute remarque sur cet itinéraire dans la section 'Additional Comments' - ci-dessous, puis envoyez depuis votre logiciel de messagerie usuel." + reportEmailTemplate: |+ + *** CONSIGNES POUR L'UTILISATEUR *** + Cet email signalera votre problème aux administrateurs de ce site. + Veuillez remplir les détails ci-dessous, puis envoyez depuis votre logiciel de messagerie habituel. + + *** VEUILLEZ REMPLIR CI-DESSOUS *** + + Problème rencontré : + + Type de trajet recherché (ex. à pied + transports, vélo + transports, voiture + transports) : + + *** DÉTAILS TECHNIQUES *** # Used in both desktop and mobile TripViewer: accessible: Accessible @@ -586,10 +586,13 @@ components: confirmDeletion: Vous avez des recherches et/ou lieux récemment enregistrés qui vont être supprimés. Voulez-vous continuer ? favoriteStops: Arrêts favoris myPreferences: Mes préférences + mySavedPlaces: Mes lieux favoris (modifier) noFavoriteStops: Aucun arrêt favori recentPlaces: Lieux récents recentSearches: Recherches récentes + recentSearchSummary: "{mode} de {from} à {to}" rememberSearches: Enregistrer les recherches/lieux récents ? + stopId: "Arrêt n° {stopId}" storageDisclaimer: > En activant cette option, tous vos lieux, préférences et paramètres seront enregistrés dans le cache de votre navigateur. @@ -615,6 +618,41 @@ components: prompt: Où désirez-vous aller ? otpUi: + DateTimeSelector: + arrive: Arrivée + depart: Départ + now: Tout de suite + queryParameters: + bikeSpeed: Vitesse à vélo + distanceInMiles: "{miles, number, :: unit/mile unit-width-full-name}" + maxBikeDistance: Distance max. à vélo + maxBikeTime: Durée max. à vélo + maxEScooterDistance: Distance max. en trottinette + maxWalkDistance: Distance max. à pied + maxWalkTime: Durée max. à pied + optimizeBikeFlat: avec moins de reliefs + optimizeBikeFriendly: plus pratiques à vélo + optimizeBikeSpeed: plus rapides + optimizeFor: Privilégier les trajets + optimizeQuick: plus rapides + optimizeTransfers: avec moins de correspondances + speedInMilesPerHour: "{mph} mi/h" + walkSpeed: Vitesse à pied + watts: Puissance de la trottinette électrique + watts125kidsHoverboard: Hoverboard pour enfants (10 km/h) + watts1500powerfulEscooter: Trottinette puissante (40 km/h) + watts250entryLevelEscooter: Trottinette de base (18 km/h) + watts500robustEscooter: Trottinette robuste (30 km/h) + # Note to translator: This text is width-constrained. + wheelchair: Trajets accessibles en fauteuil roulant + SettingsSelectorPanel: + bikeOnly: Vélo uniquement + escooterOnly: Trottinette uniquement + takeTransit: En transports + travelPreferences: Préférences de trajet + use: Utiliser # as in "Use bus, train, subway" + useCompanies: Prestataires # as in "Use companies: Spin, Lime, Bolt" + walkOnly: À pied uniquement TripDetails: calories: "Calories dépensées : {calories, number, ::.}" caloriesDescription: > @@ -696,6 +734,8 @@ common: itineraryDescriptions: calories: "{calories, number} kcal" # SI unit noItineraryToDisplay: Aucun trajet à afficher. + # Note to translator: noTransitFareProvided is width-constrained. + noTransitFareProvided: Tarif inconnu transfers: "{transfers, plural, =0 {} one {# correspondance} other {# correspondances}}" # Note to translator: some of the strings below are used in sentences such as: @@ -723,7 +763,7 @@ common: rent: Location de véhicules subway: Métro tram: Tram - transit: En transports publics + transit: En transports walk: À pied walking: Marche diff --git a/i18n/ko.yml b/i18n/ko.yml new file mode 100644 index 000000000..619aeef8d --- /dev/null +++ b/i18n/ko.yml @@ -0,0 +1,25 @@ +_id: ko +_name: Korean + +# This file contains localized strings (a.k.a. messages) for the language indicated above: +# - Messages are organized in various categories and sub-categories. +# - A component or JS module can use messages from one or more categories. +# - In the code, messages are retrieved using an ID that is simply the path to the message. +# Use the dot '.' to separate categories and sub-categories in the path. +# For instance, for the message defined in YML below: +# common +# modes +# subway: Metro# +# then use the snippet below with the corresponding message id: +# // renders "Metro". +# +# It is important that message ids in the code be consistent with +# the categories in this file. Below are some general guidelines: +# - For starters, there are an 'actions', 'components' and 'common' +# categories. Additional categories may be added as needed. +# - Each sub-category under 'components' denotes a component and +# should contain messages that are used only by that component (e.g. button captions). +# - In contrast, some strings are common to multiple components, +# so it makes sense to group them by theme (e.g. accessModes) under the 'common' category. + +# Messages that are generated from actions \ No newline at end of file diff --git a/i18n/vi.yml b/i18n/vi.yml new file mode 100644 index 000000000..db73f48f2 --- /dev/null +++ b/i18n/vi.yml @@ -0,0 +1,25 @@ +_id: vi +_name: Vietnamese + +# This file contains localized strings (a.k.a. messages) for the language indicated above: +# - Messages are organized in various categories and sub-categories. +# - A component or JS module can use messages from one or more categories. +# - In the code, messages are retrieved using an ID that is simply the path to the message. +# Use the dot '.' to separate categories and sub-categories in the path. +# For instance, for the message defined in YML below: +# common +# modes +# subway: Metro# +# then use the snippet below with the corresponding message id: +# // renders "Metro". +# +# It is important that message ids in the code be consistent with +# the categories in this file. Below are some general guidelines: +# - For starters, there are an 'actions', 'components' and 'common' +# categories. Additional categories may be added as needed. +# - Each sub-category under 'components' denotes a component and +# should contain messages that are used only by that component (e.g. button captions). +# - In contrast, some strings are common to multiple components, +# so it makes sense to group them by theme (e.g. accessModes) under the 'common' category. + +# Messages that are generated from actions \ No newline at end of file diff --git a/i18n/zh.yml b/i18n/zh.yml new file mode 100644 index 000000000..7a10dbd77 --- /dev/null +++ b/i18n/zh.yml @@ -0,0 +1,25 @@ +_id: zh +_name: Chinese + +# This file contains localized strings (a.k.a. messages) for the language indicated above: +# - Messages are organized in various categories and sub-categories. +# - A component or JS module can use messages from one or more categories. +# - In the code, messages are retrieved using an ID that is simply the path to the message. +# Use the dot '.' to separate categories and sub-categories in the path. +# For instance, for the message defined in YML below: +# common +# modes +# subway: Metro# +# then use the snippet below with the corresponding message id: +# // renders "Metro". +# +# It is important that message ids in the code be consistent with +# the categories in this file. Below are some general guidelines: +# - For starters, there are an 'actions', 'components' and 'common' +# categories. Additional categories may be added as needed. +# - Each sub-category under 'components' denotes a component and +# should contain messages that are used only by that component (e.g. button captions). +# - In contrast, some strings are common to multiple components, +# so it makes sense to group them by theme (e.g. accessModes) under the 'common' category. + +# Messages that are generated from actions \ No newline at end of file diff --git a/lib/actions/api.js b/lib/actions/api.js index d076d5a9b..b1eadcece 100644 --- a/lib/actions/api.js +++ b/lib/actions/api.js @@ -5,17 +5,19 @@ import { push, replace } from 'connected-react-router' import hash from 'object-hash' import haversine from 'haversine' -import L from 'leaflet' // Core-utils is preventing typescripting import { createAction } from 'redux-actions' import coreUtils from '@opentripplanner/core-utils' import qs from 'qs' +import { convertToPlace, getPersistenceMode } from '../util/user' import { getSecureFetchOptions } from '../util/middleware' import { getStopViewerConfig, queryIsValid } from '../util/state' -import { ItineraryView, setItineraryView } from './ui' -import { rememberPlace, zoomToStop } from './map' +import { ItineraryView, setItineraryView, setViewedStop } from './ui' +import { rememberPlace } from './user' +import v1Actions from './apiV1' +import v2Actions from './apiV2' if (typeof fetch === 'undefined') require('isomorphic-fetch') @@ -25,6 +27,11 @@ const { randId } = coreUtils.storage // Generic API actions +/* + This is not actively used, but may be again in the future to + facilitate trip monitoring, which requires a non-realtime + trip +*/ export const nonRealtimeRoutingResponse = createAction( 'NON_REALTIME_ROUTING_RESPONSE' ) @@ -40,13 +47,13 @@ export const rememberSearch = createAction('REMEMBER_SEARCH') export const forgetSearch = createAction('FORGET_SEARCH') function formatRecentPlace(place) { - return { + return convertToPlace({ ...place, icon: 'clock-o', id: `recent-${randId()}`, timestamp: new Date().getTime(), type: 'recent' - } + }) } function formatRecentSearch(url, state) { @@ -81,6 +88,24 @@ function getActiveItinerary(state) { return activeItinerary } +/** + * Dispatches a method from either v1actions or v2actions, depending on + * which version of OTP is specified in the config. + * @param {*} methodName the method to execute + * @param {...any} params varargs of params to send to the action + */ +function executeOTPAction(methodName, ...params) { + return function (dispatch, getState) { + const state = getState() + const { api } = state.otp.config + return dispatch( + api?.v2 + ? v2Actions[methodName](...params) + : v1Actions[methodName](...params) + ) + } +} + /** * Send a routing query to the OTP backend. * @@ -97,31 +122,40 @@ export function routingQuery(searchId = null, updateSearchInReducer = false) { return function (dispatch, getState) { // FIXME: batchId is searchId for now. const state = getState() + const { config, currentQuery } = state.otp + const persistenceMode = getPersistenceMode(config.persistence) const isNewSearch = !searchId if (isNewSearch) searchId = randId() // Don't permit a routing query if the query is invalid if (!queryIsValid(state)) { - console.warn( - 'Query is invalid. Aborting routing query', - state.otp.currentQuery - ) + console.warn('Query is invalid. Aborting routing query', currentQuery) return } // Reset itinerary view to default (list view). dispatch(setItineraryView(ItineraryView.LIST)) const activeItinerary = getActiveItinerary(state) - const routingType = state.otp.currentQuery.routingType + const { + combinations, + routingType, + wheelchair: accessibleRoutingEnabled + } = currentQuery // For multiple mode combinations, gather injected params from config/query. // Otherwise, inject nothing (rely on what's in current query) and perform // one iteration. - const iterations = state.otp.currentQuery.combinations - ? state.otp.currentQuery.combinations.map(({ mode, params }) => ({ - mode, - ...params - })) + const iterations = combinations + ? combinations + .map(({ mode, params }) => ({ mode, ...params })) + // Remove non-accessible combinations when accessible routing is enabled + .filter((combination) => { + if (!accessibleRoutingEnabled) return true + + // Explicit check for false is needed to avoid matching + // combinations without the parameter set (undefined) + return combination.accessible !== false + }) : [{}] dispatch( routingRequest({ @@ -133,14 +167,22 @@ export function routingQuery(searchId = null, updateSearchInReducer = false) { }) ) return Promise.all( - iterations.map((injectedParams, i) => { + iterations.map((injectedParams) => { const requestId = randId() // fetch a realtime route const url = constructRoutingQuery(state, false, { batchId: searchId, ...injectedParams }) - const realTimeFetch = fetch(url, getOtpFetchOptions(state)) + + const { user } = state + const storeTripHistory = + user && user.loggedInUser && user.loggedInUser.storeTripHistory + + const realTimeFetch = fetch( + url, + getOtpFetchOptions(state, storeTripHistory) + ) .then(getJsonAndCheckResponse) .then((json) => { const dispatchedRoutingResponse = dispatch( @@ -152,8 +194,11 @@ export function routingQuery(searchId = null, updateSearchInReducer = false) { ) // If tracking is enabled, store locations and search after successful // search is completed. - if (state.otp.user.trackRecent) { - const { from, to } = state.otp.currentQuery + if ( + persistenceMode.isLocalStorage && + state.user?.localUser?.storeTripHistory + ) { + const { from, to } = currentQuery if (!isStoredPlace(from)) { dispatch( rememberPlace({ @@ -170,8 +215,9 @@ export function routingQuery(searchId = null, updateSearchInReducer = false) { }) ) } - return dispatchedRoutingResponse + dispatch(rememberSearch(formatRecentSearch(url, state))) } + return dispatchedRoutingResponse }) .catch((error) => { dispatch(routingError({ error, requestId, searchId, url })) @@ -188,50 +234,7 @@ export function routingQuery(searchId = null, updateSearchInReducer = false) { dispatch(updateOtpUrlParams(state, searchId)) } - // Also fetch a non-realtime route. - // - // FIXME: The statement below may no longer apply with future work - // involving realtime info embedded in the OTP response. - // (That action records an entry again in the middleware.) - // For users who opted in to store trip request history, - // to avoid recording the same trip request twice in the middleware, - // only add the user Authorization token to the request - // when querying the non-realtime route. - // - // The advantage of using non-realtime route is that the middleware will be able to - // record and provide the theoretical itinerary summary without having to query OTP again. - // FIXME: Interestingly, and this could be from a side effect elsewhere, - // when a logged-in user refreshes the page, the trip request in the URL is not recorded again - // (state.user stays unpopulated until after this function is called). - // - const { user } = state - const storeTripHistory = - user && user.loggedInUser && user.loggedInUser.storeTripHistory - - const nonRealtimeFetch = fetch( - constructRoutingQuery(state, true, { - batchId: searchId, - ...injectedParams - }), - getOtpFetchOptions(state, storeTripHistory) - ) - .then(getJsonAndCheckResponse) - .then((json) => { - // FIXME: This is only performed when ignoring realtimeupdates currently, just - // to ensure it is not repeated twice. - // FIXME: We should check that the mode combination actually has - // realtime (or maybe this is set in the config file) to determine - // whether this extra query to OTP is needed. - return dispatch( - nonRealtimeRoutingResponse({ response: json, searchId }) - ) - }) - .catch((error) => { - console.error(error) - // do nothing - }) - - return Promise.all([realTimeFetch, nonRealtimeFetch]) + return realTimeFetch }) ) } @@ -288,17 +291,11 @@ function constructRoutingQuery( ) { const { config, currentQuery } = state.otp const routingType = currentQuery.routingType - const routingMode = injectedParams?.mode || currentQuery.mode - // Check for routingType-specific API config; if none, use default API const rt = config.routingTypes && config.routingTypes.find((rt) => rt.key === routingType) - - // Certain requests will require OTP-2. If an OTP-2 host is specified, set it to be used - const useOtp2 = !!config.api_v2 && routingMode.includes('FLEX') - - const api = (rt && rt.api) || (useOtp2 && config.api_v2) || config.api + const api = (rt && rt.api) || config.api const planEndpoint = `${api.host}${api.port ? ':' + api.port : ''}${ api.path }/plan` @@ -367,121 +364,21 @@ export function vehicleRentalQuery( errorAction = vehicleRentalError, options = {} ) { - const paramsString = qs.stringify(params) - const endpoint = `vehicle_rental${paramsString ? `?${paramsString}` : ''}` - return createQueryAction(endpoint, responseAction, errorAction, options) + return executeOTPAction( + 'vehicleRentalQuery', + params, + responseAction, + errorAction, + options + ) } // Single stop lookup query -const findStopResponse = createAction('FIND_STOP_RESPONSE') -const findStopError = createAction('FIND_STOP_ERROR') - -export function findStop(params) { - return createQueryAction( - `index/stops/${params.stopId}`, - findStopResponse, - findStopError, - { - noThrottle: true, - postprocess: (payload, dispatch) => { - dispatch(findRoutesAtStop(params.stopId)) - dispatch(findStopTimesForStop(params)) - }, - serviceId: 'stops' - } - ) -} +export const findStopResponse = createAction('FIND_STOP_RESPONSE') +export const findStopError = createAction('FIND_STOP_ERROR') export function fetchStopInfo(stop) { - return async function (dispatch, getState) { - await dispatch(findStop({ stopId: stop.stopId })) - const state = getState() - const { nearbyRadius: radius } = state.otp.config.stopViewer - const fetchedStop = state.otp.transitIndex.stops[stop.stopId] - if (!fetchedStop) { - return - } - - const { lat, lon } = fetchedStop - if (radius > 0) { - dispatch( - findNearbyStops( - { - includeRoutes: true, - includeStopTimes: true, - lat, - lon, - radius - }, - stop.stopId - ) - ) - dispatch(findNearbyAmenities({ lat, lon, radius }, stop.stopId)) - dispatch(zoomToStop(fetchedStop)) - } - } -} - -export const findNearbyAmenitiesResponse = createAction( - 'FIND_NEARBY_AMENITIES_RESPONSE' -) -export const findNearbyAmenitiesError = createAction( - 'FIND_NEARBY_AMENITIES_ERROR' -) - -function findNearbyAmenities(params, stopId) { - return function (dispatch, getState) { - const { lat, lon, radius } = params - const bounds = L.latLng(lat, lon).toBounds(radius) - const { lat: low, lng: left } = bounds.getSouthWest() - const { lat: up, lng: right } = bounds.getNorthEast() - dispatch( - parkAndRideQuery( - { lowerLeft: `${low},${left}`, upperRight: `${up},${right}` }, - findNearbyAmenitiesResponse, - findNearbyAmenitiesError, - { - noThrottle: true, - rewritePayload: (payload) => { - return { - parkAndRideLocations: payload, - stopId - } - } - } - ) - ) - dispatch( - bikeRentalQuery( - { lowerLeft: `${low},${left}`, upperRight: `${up},${right}` }, - findNearbyAmenitiesResponse, - findNearbyAmenitiesError, - { - rewritePayload: (payload) => { - return { - bikeRental: payload, - stopId - } - } - } - ) - ) - dispatch( - vehicleRentalQuery( - { lowerLeft: `${low},${left}`, upperRight: `${up},${right}` }, - findNearbyAmenitiesResponse, - findNearbyAmenitiesError, - { - rewritePayload: (payload) => { - return { - stopId, - vehicleRental: payload - } - } - } - ) - ) - } + return executeOTPAction('fetchStopInfo', stop) } // Single trip lookup query @@ -490,18 +387,7 @@ export const findTripResponse = createAction('FIND_TRIP_RESPONSE') export const findTripError = createAction('FIND_TRIP_ERROR') export function findTrip(params) { - return createQueryAction( - `index/trips/${params.tripId}`, - findTripResponse, - findTripError, - { - postprocess: (payload, dispatch) => { - dispatch(findStopsForTrip({ tripId: params.tripId })) - dispatch(findStopTimesForTrip({ tripId: params.tripId })) - dispatch(findGeometryForTrip({ tripId: params.tripId })) - } - } - ) + return executeOTPAction('findTrip', params) } // Stops for trip query @@ -644,6 +530,9 @@ export function findRoutes(params) { findRoutesResponse, findRoutesError, { + postprocess: (payload, dispatch) => { + dispatch(setViewedStop(null)) + }, rewritePayload: (payload) => { const routes = {} payload.forEach((rte) => { @@ -658,10 +547,12 @@ export function findRoutes(params) { // Patterns for Route lookup query // TODO: replace with GraphQL query for route => patterns => geometry -const findPatternsForRouteResponse = createAction( +export const findPatternsForRouteResponse = createAction( 'FIND_PATTERNS_FOR_ROUTE_RESPONSE' ) -const findPatternsForRouteError = createAction('FIND_PATTERNS_FOR_ROUTE_ERROR') +export const findPatternsForRouteError = createAction( + 'FIND_PATTERNS_FOR_ROUTE_ERROR' +) // Single Route lookup query @@ -669,57 +560,11 @@ export const findRouteResponse = createAction('FIND_ROUTE_RESPONSE') export const findRouteError = createAction('FIND_ROUTE_ERROR') export function findRoute(params) { - return createQueryAction( - `index/routes/${params.routeId}`, - findRouteResponse, - findRouteError, - { - noThrottle: true, - postprocess: (payload, dispatch) => { - // load patterns - dispatch(findPatternsForRoute({ routeId: params.routeId })) - } - } - ) + return executeOTPAction('findRoute', params) } export function findPatternsForRoute(params) { - return createQueryAction( - `index/routes/${params.routeId}/patterns?includeGeometry=true`, - findPatternsForRouteResponse, - findPatternsForRouteError, - { - noThrottle: true, - postprocess: (payload, dispatch) => { - // load geometry for each pattern - payload.forEach((ptn) => { - // Some OTP instances don't support includeGeometry. - // We need to manually fetch geometry in these cases. - if (!ptn.geometry) { - dispatch( - findGeometryForPattern({ - patternId: ptn.id, - routeId: params.routeId - }) - ) - } - }) - }, - - rewritePayload: (payload) => { - // convert pattern array to ID-mapped object - const patterns = {} - payload.forEach((ptn) => { - patterns[ptn.id] = ptn - }) - - return { - patterns, - routeId: params.routeId - } - } - } - ) + return executeOTPAction('findPatternsForRoute', params) } // Geometry for Pattern lookup query @@ -737,6 +582,7 @@ export function findGeometryForPattern(params) { findGeometryForPatternResponse, findGeometryForPatternError, { + noThrottle: true, rewritePayload: (payload) => { return { geometry: payload, @@ -834,9 +680,15 @@ export function getTransportationNetworkCompanyRideEstimate(params) { } // Nearby Stops Query +export const findNearbyAmenitiesResponse = createAction( + 'FIND_NEARBY_AMENITIES_RESPONSE' +) +export const findNearbyAmenitiesError = createAction( + 'FIND_NEARBY_AMENITIES_ERROR' +) -const receivedNearbyStopsResponse = createAction('NEARBY_STOPS_RESPONSE') -const receivedNearbyStopsError = createAction('NEARBY_STOPS_ERROR') +export const receivedNearbyStopsResponse = createAction('NEARBY_STOPS_RESPONSE') +export const receivedNearbyStopsError = createAction('NEARBY_STOPS_ERROR') export function findNearbyStops(params, focusStopId) { return createQueryAction( @@ -846,9 +698,8 @@ export function findNearbyStops(params, focusStopId) { { noThrottle: true, postprocess: (stops, dispatch, getState) => { - if (params.max && stops.length > params.max) { + if (params.max && stops.length > params.max) stops = stops.slice(0, params.max) - } }, rewritePayload: (stops) => { if (stops) { @@ -862,9 +713,8 @@ export function findNearbyStops(params, focusStopId) { stops.sort((a, b) => { return a.distance - b.distance }) - if (params.max && stops.length > params.max) { + if (params.max && stops.length > params.max) stops = stops.slice(0, params.max) - } } return { focusStopId, stops } }, @@ -916,27 +766,15 @@ export const clearStops = createAction('CLEAR_STOPS_OVERLAY') // Realtime Vehicle positions query -const receivedVehiclePositions = createAction( +export const receivedVehiclePositions = createAction( 'REALTIME_VEHICLE_POSITIONS_RESPONSE' ) -const receivedVehiclePositionsError = createAction( +export const receivedVehiclePositionsError = createAction( 'REALTIME_VEHICLE_POSITIONS_ERROR' ) export function getVehiclePositionsForRoute(routeId) { - return createQueryAction( - `index/routes/${routeId}/vehicles`, - receivedVehiclePositions, - receivedVehiclePositionsError, - { - rewritePayload: (payload) => { - return { - routeId: routeId, - vehicles: payload - } - } - } - ) + return executeOTPAction('getVehiclePositionsForRoute', routeId) } const throttledUrls = {} @@ -996,7 +834,7 @@ function handleThrottlingUrl(url, fetchOptions) { * alternateTransitIndex configuration. * - fetchOptions: fetch options (e.g., method, body, headers). */ -function createQueryAction( +export function createQueryAction( endpoint, responseAction, errorAction, @@ -1006,20 +844,7 @@ function createQueryAction( return async function (dispatch, getState) { const state = getState() const { config } = state.otp - let url - if ( - options.serviceId && - config.alternateTransitIndex && - config.alternateTransitIndex.services.includes(options.serviceId) - ) { - console.log('Using alt service for ' + options.serviceId) - url = config.alternateTransitIndex.apiRoot + endpoint - } else { - const api = config.api - url = `${api?.host}${api?.port ? ':' + api.port : ''}${ - api?.path - }/${endpoint}` - } + const url = makeApiUrl(config, endpoint, options) if (!options.noThrottle) { // Don't make a request to a URL that has already seen the same request @@ -1029,17 +854,19 @@ function createQueryAction( let payload try { - const response = await fetch(url, getOtpFetchOptions(state)) + // Need to merge headers to support graphQL POST request with an api key + const mergedHeaders = { + ...getOtpFetchOptions(state)?.headers, + ...options.fetchOptions?.headers + } + + const response = await fetch(url, { + ...getOtpFetchOptions(state), + ...options.fetchOptions, + headers: mergedHeaders + }) + if (response.status >= 400) { - // If a second endpoint is configured, try that before failing - if (!!config.api_v2 && !options.v2) { - return dispatch( - createQueryAction(endpoint, responseAction, errorAction, { - ...options, - v2: true - }) - ) - } const error = new Error('Received error from server') error.response = response throw error @@ -1050,7 +877,9 @@ function createQueryAction( } if (typeof options.rewritePayload === 'function') { - dispatch(responseAction(options.rewritePayload(payload))) + dispatch( + responseAction(options.rewritePayload(payload, dispatch, getState)) + ) } else { dispatch(responseAction(payload)) } @@ -1079,31 +908,16 @@ function makeApiUrl(config, endpoint, options) { console.log('Using alt service for ' + options.serviceId) url = config.alternateTransitIndex.apiRoot + endpoint } else { - const api = options.v2 ? config.api_v2 : config.api + const api = config.api + + // Don't crash if no api is defined (such as in the unit test env) + if (!api?.host) return null + url = `${api.host}${api.port ? ':' + api.port : ''}${api.path}/${endpoint}` } return url } -// TODO: Determine how we might be able to use GraphQL with the alternative -// transit index. Currently this is not easily possible because the alternative -// transit index does not have support for GraphQL and handling both Rest and -// GraphQL queries could introduce potential difficulties for maintainers. -// function createGraphQLQueryAction (query, variables, responseAction, errorAction, options) { -// const endpoint = `index/graphql` -// const fetchOptions = { -// method: 'POST', -// body: JSON.stringify({ query, variables }), -// headers: { 'Content-Type': 'application/json' } -// } -// return createQueryAction( -// endpoint, -// responseAction, -// errorAction, -// { ...options, fetchOptions } -// ) -// } - /** * Update the browser/URL history with new parameters * NOTE: This has not been tested for profile-based journeys. diff --git a/lib/actions/apiV1.js b/lib/actions/apiV1.js new file mode 100644 index 000000000..e255bd863 --- /dev/null +++ b/lib/actions/apiV1.js @@ -0,0 +1,231 @@ +import L from 'leaflet' +import qs from 'qs' + +import { + bikeRentalQuery, + createQueryAction, + findGeometryForPattern, + findGeometryForTrip, + findNearbyAmenitiesError, + findNearbyAmenitiesResponse, + findNearbyStops, + findPatternsForRouteError, + findPatternsForRouteResponse, + findRouteError, + findRouteResponse, + findRoutesAtStop, + findStopError, + findStopResponse, + findStopsForTrip, + findStopTimesForStop, + findStopTimesForTrip, + findTripError, + findTripResponse, + parkAndRideQuery, + receivedVehiclePositions, + receivedVehiclePositionsError +} from './api' +import { setViewedStop } from './ui' +import { zoomToStop } from './map' + +const findTrip = (params) => + createQueryAction( + `index/trips/${params.tripId}`, + findTripResponse, + findTripError, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + dispatch(findStopsForTrip({ tripId: params.tripId })) + dispatch(findStopTimesForTrip({ tripId: params.tripId })) + dispatch(findGeometryForTrip({ tripId: params.tripId })) + } + } + ) + +export function vehicleRentalQuery( + params, + responseAction, + errorAction, + options +) { + const paramsString = qs.stringify(params) + const endpoint = `vehicle_rental${paramsString ? `?${paramsString}` : ''}` + return createQueryAction(endpoint, responseAction, errorAction, options) +} + +function findNearbyAmenities({ lat, lon, radius = 300 }, stopId) { + return function (dispatch, getState) { + const bounds = L.latLng(lat, lon).toBounds(radius) + const { lat: low, lng: left } = bounds.getSouthWest() + const { lat: up, lng: right } = bounds.getNorthEast() + dispatch( + parkAndRideQuery( + { lowerLeft: `${low},${left}`, upperRight: `${up},${right}` }, + findNearbyAmenitiesResponse, + findNearbyAmenitiesError, + { + noThrottle: true, + rewritePayload: (payload) => { + return { + parkAndRideLocations: payload, + stopId + } + } + } + ) + ) + dispatch( + bikeRentalQuery( + { lowerLeft: `${low},${left}`, upperRight: `${up},${right}` }, + findNearbyAmenitiesResponse, + findNearbyAmenitiesError, + { + rewritePayload: (payload) => { + return { + bikeRental: payload, + stopId + } + } + } + ) + ) + dispatch( + vehicleRentalQuery( + { lowerLeft: `${low},${left}`, upperRight: `${up},${right}` }, + findNearbyAmenitiesResponse, + findNearbyAmenitiesError, + { + rewritePayload: (payload) => { + return { + stopId, + vehicleRental: payload + } + } + } + ) + ) + } +} + +export const findStop = (params) => + createQueryAction( + `index/stops/${params.stopId}`, + findStopResponse, + findStopError, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + dispatch(findRoutesAtStop(params.stopId)) + dispatch(findStopTimesForStop(params)) + }, + serviceId: 'stops' + } + ) + +const fetchStopInfo = (stop) => + async function (dispatch, getState) { + await dispatch(findStop({ stopId: stop.stopId })) + const state = getState() + const { nearbyRadius } = state.otp?.config?.stopViewer + const fetchedStop = state.otp.transitIndex.stops?.[stop?.stopId] + // TODO: stop not found message + if (!fetchedStop) return + + const { lat, lon } = fetchedStop + if (nearbyRadius > 0) { + dispatch( + findNearbyStops( + { + includeRoutes: true, + includeStopTimes: true, + lat, + lon, + nearbyRadius + }, + stop.stopId + ) + ) + dispatch( + findNearbyAmenities({ lat, lon, radius: nearbyRadius }, stop.stopId) + ) + dispatch(zoomToStop(fetchedStop)) + } + } + +const getVehiclePositionsForRoute = (routeId) => + createQueryAction( + `index/routes/${routeId}/vehicles`, + receivedVehiclePositions, + receivedVehiclePositionsError, + { + rewritePayload: (payload) => { + return { + routeId: routeId, + vehicles: payload + } + } + } + ) + +export const findPatternsForRoute = (params) => + createQueryAction( + `index/routes/${params.routeId}/patterns?includeGeometry=true`, + findPatternsForRouteResponse, + findPatternsForRouteError, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + // load geometry for each pattern + payload.forEach((ptn) => { + // Some OTP instances don't support includeGeometry. + // We need to manually fetch geometry in these cases. + if (!ptn.geometry) { + dispatch( + findGeometryForPattern({ + patternId: ptn.id, + routeId: params.routeId + }) + ) + } + }) + }, + + rewritePayload: (payload) => { + // convert pattern array to ID-mapped object + const patterns = {} + payload.forEach((ptn) => { + patterns[ptn.id] = ptn + }) + + return { + patterns, + routeId: params.routeId + } + } + } + ) + +export const findRoute = (params) => + createQueryAction( + `index/routes/${params.routeId}`, + findRouteResponse, + findRouteError, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + // load patterns + dispatch(findPatternsForRoute({ routeId: params.routeId })) + dispatch(setViewedStop(null)) + } + } + ) + +export default { + fetchStopInfo, + findPatternsForRoute, + findRoute, + findTrip, + getVehiclePositionsForRoute, + vehicleRentalQuery +} diff --git a/lib/actions/apiV2.js b/lib/actions/apiV2.js new file mode 100644 index 000000000..41767d454 --- /dev/null +++ b/lib/actions/apiV2.js @@ -0,0 +1,548 @@ +import clone from 'clone' + +import { + createQueryAction, + findGeometryForTrip, + findNearbyAmenitiesError, + findNearbyAmenitiesResponse, + findRouteError, + findRouteResponse, + findStopError, + findStopResponse, + findStopTimesForTrip, + findTripError, + findTripResponse, + receivedNearbyStopsError, + receivedNearbyStopsResponse +} from './api' +import { setMapZoom } from './config' +import { zoomToStop } from './map' + +/** + * Generic helper for crafting GraphQL queries. + */ +function createGraphQLQueryAction( + query, + variables, + responseAction, + errorAction, + options +) { + const endpoint = 'index/graphql' + const fetchOptions = { + body: JSON.stringify({ query, variables }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST' + } + return createQueryAction(endpoint, responseAction, errorAction, { + ...options, + fetchOptions + }) +} + +const findTrip = (params) => + createGraphQLQueryAction( + `{ + trip(id: "${params.tripId}") { + id: gtfsId + route { + id: gtfsId + agency { + id: gtfsId + name + url + timezone + lang + phone + fareUrl + } + shortName + longName + type + url + color + textColor + routeBikesAllowed: bikesAllowed + bikesAllowed + } + serviceId + tripHeadsign + directionId + blockId + shapeId + wheelchairAccessible + bikesAllowed + tripBikesAllowed: bikesAllowed + + stops { + id: gtfsId + stopId: gtfsId + code + name + lat + lon + } + + tripGeometry { + length + points + } + } + }`, + {}, + findTripResponse, + findTripError, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + // FIXME: integrate into graphql request + dispatch(findStopTimesForTrip({ tripId: params.tripId })) + dispatch(findGeometryForTrip({ tripId: params.tripId })) + }, + rewritePayload: (payload) => { + if (!payload?.data?.trip) return {} + + payload.data.trip.geometry = payload.data.trip.tripGeometry + return payload.data.trip + } + } + ) + +export const vehicleRentalQuery = ( + params, + responseAction, + errorAction, + options +) => + // TODO: ErrorsByNetwork is missing + createGraphQLQueryAction( + `{ + rentalVehicles { + vehicleId + id + name + lat + lon + allowPickupNow + network + } + } + `, + {}, + responseAction, + errorAction, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + if (payload.errors) { + return errorAction(payload.errors) + } + }, + // TODO: most of this rewrites the OTP2 response to match OTP1. + // we should re-write the rest of the UI to match OTP's behavior instead + rewritePayload: (payload) => { + return { + stations: payload?.data?.rentalVehicles.map((vehicle) => { + return { + allowPickup: vehicle.allowPickupNow, + id: vehicle.vehicleId, + name: vehicle.name, + networks: [vehicle.network], + x: vehicle.lon, + y: vehicle.lat + } + }) + } + } + } + ) + +const stopTimeGraphQLQuery = ` +stopTimes: stoptimesForPatterns(numberOfDepartures: 3) { + pattern { + desc: name + headsign + id: code + } + times: stoptimes { + arrivalDelay + departureDelay + headsign + realtime + realtimeArrival + realtimeDeparture + realtimeState + scheduledArrival + scheduledDeparture + serviceDay + stop { + id: gtfsId + } + timepoint + trip { + id + } + } +} +` + +const stopGraphQLQuery = ` +id: gtfsId +lat +lon +locationType +name +wheelchairBoarding +zoneId +geometries { + geoJson +} +routes { + id: gtfsId + agency { + gtfsId + name + } + longName + mode + color + shortName +} +${stopTimeGraphQLQuery} +` + +const findNearbyStops = ({ focusStopId, lat, lon, radius = 300 }) => { + if (!focusStopId) return {} + return createGraphQLQueryAction( + `{ + stopsByRadius(lat: ${lat}, lon: ${lon}, radius: ${radius}) { + edges { + node { + stop { + ${stopGraphQLQuery} + } + } + } + } + }`, + {}, + receivedNearbyStopsResponse, + receivedNearbyStopsError, + { + noThrottle: true, + rewritePayload: (payload) => { + return { + focusStopId, + stops: payload?.data?.stopsByRadius?.edges?.map((edge) => { + const { stop } = edge.node + return { + ...stop, + agencyId: stop?.route?.agency?.gtfsId, + agencyName: stop?.route?.agency?.name + } + }) + } + } + } + ) +} +const findNearbyAmenities = ({ lat, lon, radius = 300, stopId }) => { + if (!stopId) return {} + return createGraphQLQueryAction( + `{ + bikeNearest: nearest(lat: ${lat}, lon: ${lon}, maxDistance: ${radius}, filterByPlaceTypes: BIKE_PARK) { + edges { + node { + place { + id + lat + lon + ...on VehicleRentalStation { + network + } + } + distance + } + } + } + scooterNearest: nearest(lat: ${lat}, lon: ${lon}, maxDistance: ${radius}, filterByPlaceTypes: BICYCLE_RENT) { + edges { + node { + place { + id + lat + lon + ...on RentalVehicle { + network + } + } + distance + } + } + } + parkingLotNearest: nearest(lat: ${lat}, lon: ${lon}, maxDistance: ${radius}, filterByPlaceTypes: CAR_PARK) { + edges { + node { + place { + id + lat + lon + } + distance + } + } + } + }`, + {}, + findNearbyAmenitiesResponse, + findNearbyAmenitiesError, + { + noThrottle: true, + rewritePayload: (payload) => { + if (!payload.data) + return { + bikeRental: { stations: [] }, + vehicleRentalQuery: { stations: [] } + } + // TODO: no way to get full bike info right now from endpoint. Would have + // to make an additional request, or patch OTP2 + return { + bikeRental: { + stations: payload.data.bikeNearest?.edges.map((edge) => { + return { + distance: edge.node.distance, + id: edge.node?.place?.id, + isFloatingBike: true, + lat: edge.node?.place?.lat, + lon: edge.node?.place?.lon, + name: edge.node?.place?.id || '', + networks: [edge.node?.place?.network || 'null'], + x: edge.node?.place?.lon, + y: edge.node?.place?.lat + } + }) + }, + parkAndRideLocations: payload.data.parkingLotNearest?.edges.map( + (edge) => { + return { + distance: edge.node.distance, + id: edge.node?.place?.id, + isFloatingBike: true, + lat: edge.node?.place?.lat, + lon: edge.node?.place?.lon, + name: edge.node?.place?.id || '', + networks: ['null'], + x: edge.node?.place?.lon, + y: edge.node?.place?.lat + } + } + ), + stopId, + vehicleRental: { + stations: payload.data.scooterNearest?.edges.map((edge) => { + return { + distance: edge.node.distance, + id: edge.node?.place?.id, + isFloatingBike: true, + lat: edge.node?.place?.lat, + lon: edge.node?.place?.lon, + name: edge.node?.place?.id || '', + networks: [edge.node?.place?.network || 'null'], + x: edge.node?.place?.lon, + y: edge.node?.place?.lat + } + }) + } + } + } + } + ) +} + +const fetchStopInfo = (stop) => { + const { stopId } = stop + if (!stopId) + return function (dispatch, getState) { + console.warn("No stopId passed, can't fetch stop!") + } + + return createGraphQLQueryAction( + `{ + stop(id: "${stopId}") { + ${stopGraphQLQuery} + } + } +`, + {}, + findStopResponse, + findStopError, + { + noThrottle: true, + postprocess: (payload, dispatch) => { + if (payload.errors) { + return findStopError(payload.errors) + } + const { stop } = payload?.data + if (!stop || !stop.lat || !stop.lon) return findStopError() + + // Fetch nearby stops and amenities + // TODO: add radius from config + dispatch( + findNearbyAmenities({ + lat: stop.lat, + lon: stop.lon, + stopId: stop.id + }) + ) + // TODO: add radius from config + dispatch( + findNearbyStops({ + focusStopId: stop.id, + lat: stop.lat, + lon: stop.lon + }) + ) + + dispatch(zoomToStop(stop)) + + if (stop?.geometries?.geoJson?.type !== 'Point') { + dispatch(setMapZoom(10)) + } + }, + rewritePayload: (payload) => { + const { stop } = payload?.data + if (!stop) return findStopError() + + const color = stop.routes?.length > 0 && `#${stop.routes[0].color}` + + // Doing some OTP1 compatibility rewriting here + return { + ...stop, + agencyId: stop?.route?.agency?.gtfsId, + agencyName: stop?.route?.agency?.name, + color + } + }, + serviceId: 'stops' + } + ) +} + +const getVehiclePositionsForRoute = () => + function (dispatch, getState) { + console.warn('OTP2 does not yet support vehicle positions for route!') + } + +export const findRoute = (params) => + function (dispatch, getState) { + const { routeId } = params + if (!routeId) return + + return dispatch( + createGraphQLQueryAction( + `{ + route(id: "${routeId}") { + id: gtfsId + desc + agency { + id: gtfsId + name + url + timezone + lang + phone + } + longName + type + color + textColor + bikesAllowed + routeBikesAllowed: bikesAllowed + + patterns { + id + name + patternGeometry { + points + length + } + stops { + code + id: gtfsId + lat + lon + name + locationType + geometries { + geoJson + } + routes { + color + } + } + } + } + } + `, + {}, + findRouteResponse, + findRouteError, + { + noThrottle: true, + // TODO: avoid re-writing OTP2 route object to match OTP1 style + rewritePayload: (payload) => { + if (payload.errors) { + return dispatch(findRouteError(payload.errors)) + } + const { route } = payload?.data + if (!route) return + + const newRoute = clone(route) + const routePatterns = {} + newRoute.patterns.forEach((pattern) => { + const patternStops = pattern.stops.map((stop) => { + const color = + stop.routes?.length > 0 && `#${stop.routes[0].color}` + if (stop.routes) delete stop.routes + return { ...stop, color } + }) + routePatterns[pattern.id] = { + ...pattern, + desc: pattern.name, + geometry: pattern.patternGeometry, + stops: patternStops + } + }) + newRoute.patterns = routePatterns + // TODO: avoid explicit behavior shift like this + newRoute.v2 = true + + return newRoute + } + } + ) + ) + } + +export const findPatternsForRoute = (params) => + function (dispatch, getState) { + const state = getState() + const { routeId } = params + const route = state?.otp?.transitIndex?.routes?.[routeId] + if (!route.patterns) { + // TODO: since grabbbing only patterns would basically be the same query and + // most crucially re-writing as findRoute() already does, we just make that request + // + // A proper graphQL implementation will only grab what data is needed when it is needed + return dispatch(findRoute(params)) + } + } + +export default { + fetchStopInfo, + findPatternsForRoute, + findRoute, + findTrip, + getVehiclePositionsForRoute, + vehicleRentalQuery +} diff --git a/lib/actions/form.js b/lib/actions/form.js index a385b30a7..ca18c8ff0 100644 --- a/lib/actions/form.js +++ b/lib/actions/form.js @@ -1,20 +1,22 @@ +// TODO: Typescript +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { createAction } from 'redux-actions' import coreUtils from '@opentripplanner/core-utils' import debounce from 'lodash.debounce' import isEqual from 'lodash.isequal' import moment from 'moment' import qs from 'qs' -import { createAction } from 'redux-actions' import { queryIsValid } from '../util/state' -import { routingQuery } from './api' -import { setLocation } from './map' import { MobileScreens, routeTo, setMainPanelContent, setMobileScreen } from './ui' +import { routingQuery } from './api' +import { setLocation } from './map' const { getDefaultQuery, @@ -34,7 +36,7 @@ export const storeDefaultSettings = createAction('STORE_DEFAULT_SETTINGS') * @param {Boolean} [full=false] if set to true, the from/to locations and URL * query parameters will also be reset. */ -export function resetForm (full = false) { +export function resetForm(full = false) { return function (dispatch, getState) { const state = getState() const { transitModes } = state.otp.config.modes @@ -51,15 +53,15 @@ export function resetForm (full = false) { const options = getTripOptionsFromQuery(defaultQuery) // Default mode is currently WALK,TRANSIT. We need to update this value // here to match the list of modes, otherwise the form will break. - options.mode = ['WALK', ...transitModes.map(m => m.mode)].join(',') + options.mode = ['WALK', ...transitModes.map((m) => m.mode)].join(',') dispatch(settingQueryParam(options)) } if (full) { // If fully resetting form, also clear the active search, from/to // locations, and query params. dispatch(clearActiveSearch()) - dispatch(setLocation({location: null, locationType: 'from'})) - dispatch(setLocation({location: null, locationType: 'to'})) + dispatch(setLocation({ location: null, locationType: 'from' })) + dispatch(setLocation({ location: null, locationType: 'to' })) // Get query params. Delete everything except sessionId. const params = getUrlParams() for (const key in params) { @@ -75,7 +77,7 @@ export function resetForm (full = false) { * parameter-specific actions. If a search ID is provided, a routing query (OTP * search) will be kicked off immediately. */ -export function setQueryParam (payload, searchId) { +export function setQueryParam(payload, searchId) { return function (dispatch, getState) { dispatch(settingQueryParam(payload)) if (searchId) dispatch(routingQuery(searchId)) @@ -93,18 +95,22 @@ export function setQueryParam (payload, searchId) { * query made during a call taker session, to prevent * a search from being added to the current call) */ -export function parseUrlQueryString (params = getUrlParams(), source) { +export function parseUrlQueryString(params = getUrlParams(), source) { return function (dispatch, getState) { + const state = getState() + if (state.otp.ui.mainPanelContent !== null) return state + // Filter out the OTP (i.e. non-UI) params and set the initial query const planParams = {} - Object.keys(params).forEach(key => { + Object.keys(params).forEach((key) => { if (!key.startsWith('ui_')) planParams[key] = params[key] }) let searchId = params.ui_activeSearch || coreUtils.storage.randId() if (source) searchId += source // Convert strings to numbers/objects and dispatch - planParamsToQueryAsync(planParams, getState().otp.config) - .then(query => dispatch(setQueryParam(query, searchId))) + planParamsToQueryAsync(planParams, getState().otp.config).then((query) => + dispatch(setQueryParam(query, searchId)) + ) } } @@ -117,18 +123,14 @@ let lastDebouncePlanTimeMs * (based on autoPlan strategies) as well as updating the UI state (esp. for * mobile). */ -export function formChanged (oldQuery, newQuery) { +export function formChanged(oldQuery, newQuery) { return function (dispatch, getState) { const state = getState() const { config, currentQuery, ui } = state.otp const { autoPlan, debouncePlanTimeMs } = config const isMobile = coreUtils.ui.isMobile() - const { - fromChanged, - oneLocationChanged, - shouldReplanTrip, - toChanged - } = checkShouldReplanTrip(autoPlan, isMobile, oldQuery, newQuery) + const { fromChanged, oneLocationChanged, shouldReplanTrip, toChanged } = + checkShouldReplanTrip(autoPlan, isMobile, oldQuery, newQuery) // If departArrive is set to 'NOW', update the query time to current if (currentQuery.departArrive === 'NOW') { const now = moment().format(coreUtils.time.OTP_API_TIME_FORMAT) @@ -137,7 +139,7 @@ export function formChanged (oldQuery, newQuery) { // 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. - if (oneLocationChanged) { + if (oneLocationChanged && !isMobile) { dispatch(setMainPanelContent(null)) } if (!shouldReplanTrip) { @@ -156,7 +158,10 @@ export function formChanged (oldQuery, newQuery) { // 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) + debouncedPlanTrip = debounce( + () => dispatch(routingQuery()), + debouncePlanTimeMs + ) lastDebouncePlanTimeMs = debouncePlanTimeMs } debouncedPlanTrip() @@ -169,15 +174,15 @@ export function formChanged (oldQuery, newQuery) { * 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) { +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) + const oneLocationChanged = + (fromChanged && !toChanged) || (!fromChanged && toChanged) // Check whether a trip should be auto-replanned - const strategy = isMobile && autoPlan?.mobile - ? autoPlan?.mobile - : autoPlan?.default + const strategy = + isMobile && autoPlan?.mobile ? autoPlan?.mobile : autoPlan?.default const shouldReplanTrip = evaluateAutoPlanStrategy( strategy, fromChanged, @@ -198,7 +203,12 @@ export function checkShouldReplanTrip (autoPlan, isMobile, oldQuery, newQuery) { * 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) => { +const evaluateAutoPlanStrategy = ( + strategy, + fromChanged, + toChanged, + oneLocationChanged +) => { switch (strategy) { case 'ONE_LOCATION_CHANGED': if (oneLocationChanged) return true @@ -206,7 +216,9 @@ const evaluateAutoPlanStrategy = (strategy, fromChanged, toChanged, oneLocationC case 'BOTH_LOCATIONS_CHANGED': if (fromChanged && toChanged) return true break - case 'ANY': return true - default: return false + case 'ANY': + return true + default: + return false } } diff --git a/lib/actions/map.js b/lib/actions/map.js index 5db88e68f..b92c10acd 100644 --- a/lib/actions/map.js +++ b/lib/actions/map.js @@ -3,6 +3,7 @@ import coreUtils from '@opentripplanner/core-utils' import getGeocoder from '@opentripplanner/geocoder' import { clearActiveSearch } from './form' +import { deleteUserPlace } from './user' import { routingQuery } from './api' import { setMapCenter, setMapZoom } from './config' @@ -20,12 +21,21 @@ import { setMapCenter, setMapZoom } from './config' // Private actions const clearingLocation = createAction('CLEAR_LOCATION') const settingLocation = createAction('SET_LOCATION') +const deleteRecentPlace = createAction('DELETE_LOCAL_USER_RECENT_PLACE') -// Public actions -export const forgetPlace = createAction('FORGET_PLACE') -export const rememberPlace = createAction('REMEMBER_PLACE') -export const forgetStop = createAction('FORGET_STOP') -export const rememberStop = createAction('REMEMBER_STOP') +/** + * Dispatches the action to delete a saved or recent place from localStorage. + */ +export function forgetPlace(placeId, intl) { + return function (dispatch, getState) { + // localStorage only: Recent place IDs contain the string literal 'recent'. + if (placeId.indexOf('recent') !== -1) { + dispatch(deleteRecentPlace(placeId)) + } else { + dispatch(deleteUserPlace(placeId, intl)) + } + } +} export function clearLocation(payload) { return function (dispatch, getState) { @@ -110,16 +120,16 @@ export function onLocationSelected( } export function switchLocations() { - return function (dispatch, getState) { + return async function (dispatch, getState) { const { from, to } = getState().otp.currentQuery // First, reverse the locations. - dispatch( + await dispatch( settingLocation({ location: to, locationType: 'from' }) ) - dispatch( + await dispatch( settingLocation({ location: from, locationType: 'to' @@ -131,6 +141,7 @@ export function switchLocations() { } export const setLegDiagram = createAction('SET_LEG_DIAGRAM') +export const setMapillaryId = createAction('SET_MAPILLARY_ID') export const setElevationPoint = createAction('SET_ELEVATION_POINT') diff --git a/lib/actions/narrative.js b/lib/actions/narrative.js index 48a75d611..2a30e880d 100644 --- a/lib/actions/narrative.js +++ b/lib/actions/narrative.js @@ -1,9 +1,10 @@ -import coreUtils from '@opentripplanner/core-utils' +/* eslint-disable @typescript-eslint/no-use-before-define */ import { createAction } from 'redux-actions' +import coreUtils from '@opentripplanner/core-utils' import { setUrlSearch } from './api' -export function setActiveItinerary (payload) { +export function setActiveItinerary(payload) { return function (dispatch, getState) { // Trigger change in store. dispatch(settingActiveitinerary(payload)) @@ -16,7 +17,6 @@ export function setActiveItinerary (payload) { const settingActiveitinerary = createAction('SET_ACTIVE_ITINERARY') export const setActiveLeg = createAction('SET_ACTIVE_LEG') export const setActiveStep = createAction('SET_ACTIVE_STEP') -export const setUseRealtimeResponse = createAction('SET_USE_REALTIME_RESPONSE') // Set itinerary visible on map. This is used for mouse over effects with // itineraries in the list. export const setVisibleItinerary = createAction('SET_VISIBLE_ITINERARY') diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 659363fa8..6f50b72b6 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -1,24 +1,70 @@ -import { push } from 'connected-react-router' -import coreUtils from '@opentripplanner/core-utils' import { createAction } from 'redux-actions' import { matchPath } from 'react-router' +import { push } from 'connected-react-router' +import coreUtils from '@opentripplanner/core-utils' -import { getUiUrlParams, getModesForActiveAgencyFilter } from '../util/state' -import { getDefaultLocale, loadLocaleData } from '../util/i18n' +import { + getConfigLocales, + getDefaultLocale, + getMatchingLocaleString, + loadLocaleData +} from '../util/i18n' +import { getModesForActiveAgencyFilter, getUiUrlParams } from '../util/state' import { getPathFromParts } from '../util/ui' -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 { 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 toggleAutoRefresh = createAction('TOGGLE_AUTO_REFRESH') +const setPreviousItineraryView = createAction('SET_PREVIOUS_ITINERARY_VIEW') + /** * Wrapper function for history#push (or, if specified, replace, etc.) * that preserves the current search or, if @@ -28,7 +74,7 @@ const updateLocale = createAction('UPDATE_LOCALE') * @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) { +export function routeTo(url, replaceSearch, routingMethod = push) { return function (dispatch, getState) { // Get search to preserve when routing to new path. const { router } = getState() @@ -43,13 +89,82 @@ export function routeTo (url, replaceSearch, routingMethod = push) { } } +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) { +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 @@ -63,7 +178,12 @@ export function matchContentToUrl (location) { switch (root) { case 'route': if (id) { - dispatch(findRoute({ routeId: 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, @@ -75,8 +195,8 @@ export function matchContentToUrl (location) { dispatch(setViewedRoute({ patternId, routeId: id })) } else { dispatch(setViewedRoute(null)) - dispatch(setMainPanelContent(MainPanelContent.ROUTE_VIEWER)) } + dispatch(setMainPanelContent(MainPanelContent.ROUTE_VIEWER)) break case 'stop': if (id) { @@ -87,12 +207,13 @@ export function matchContentToUrl (location) { } break case 'start': - case '@': + 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) @@ -103,6 +224,7 @@ export function matchContentToUrl (location) { if (routerId) dispatch(setRouterId(routerId)) dispatch(setMainPanelContent(null)) break + } // For any other route path, just revert to default panel. default: dispatch(setMainPanelContent(null)) @@ -111,19 +233,11 @@ export function matchContentToUrl (location) { } } -/** - * 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) -} - /** * 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) { +export function handleBackButtonPress(e) { return function (dispatch, getState) { const state = getState() const { activeSearchId } = state.otp @@ -152,7 +266,9 @@ export function handleBackButtonPress (e) { dispatch(clearLocation({ type: 'from' })) dispatch(clearLocation({ type: 'to' })) } else if (previousSearchId) { - console.warn(`No search found in state history for search ID: ${previousSearchId}. Replanning...`) + 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 @@ -163,127 +279,11 @@ export function handleBackButtonPress (e) { } } -export const setMobileScreen = createAction('SET_MOBILE_SCREEN') - -/** - * 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 - } - } -} - -const setPanel = createAction('SET_MAIN_PANEL_CONTENT') -export const clearPanel = createAction('CLEAR_MAIN_PANEL') - -// 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) - dispatch(routeTo(path)) - } -} - -const viewStop = createAction('SET_VIEWED_STOP') - -export const setHoveredStop = createAction('SET_HOVERED_STOP') - -export const setViewedTrip = createAction('SET_VIEWED_TRIP') - -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)) - } -} - -const viewRoute = createAction('SET_VIEWED_ROUTE') - -export const toggleAutoRefresh = createAction('TOGGLE_AUTO_REFRESH') - -// UI state enums - -export const MainPanelContent = { - ROUTE_VIEWER: 1, - STOP_VIEWER: 2 -} - -export const MobileScreens = { - WELCOME_SCREEN: 1, - // eslint-disable-next-line sort-keys - SET_INITIAL_LOCATION: 2, - // eslint-disable-next-line sort-keys - SEARCH_FORM: 3, - SET_FROM_LOCATION: 4, - SET_TO_LOCATION: 5, - // eslint-disable-next-line sort-keys - SET_OPTIONS: 6, - // eslint-disable-next-line sort-keys - SET_DATETIME: 7, - // eslint-disable-next-line sort-keys - RESULTS_SUMMARY: 8 -} - -/** - * 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 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) { +export function setItineraryView(value) { return function (dispatch, getState) { const urlParams = coreUtils.query.getUrlParams() const prevItineraryView = urlParams.ui_itineraryView || ItineraryView.LIST @@ -305,10 +305,10 @@ export function setItineraryView (value) { } /** - * 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 () { + * 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 @@ -327,7 +327,7 @@ export function toggleBatchResultsMap () { /** * Takes the user back to the mobile search screen in mobile views. */ -export function showMobileSearchScreen () { +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.) @@ -343,15 +343,26 @@ export function showMobileSearchScreen () { * set in the configuration. * Also update the lang attribute on the root element for accessibility. */ -export function setLocale (locale) { +export function setLocale(locale) { return async function (dispatch, getState) { const { config } = getState().otp + const { loggedInUser } = getState().user const { language: customMessages } = config - const effectiveLocale = locale || getDefaultLocale(config) - const messages = await loadLocaleData(effectiveLocale, customMessages) + 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 - dispatch(updateLocale({ locale: effectiveLocale, messages })) + // Update the redux state, only with a matched locale + dispatch(updateLocale({ locale: matchedLocale, messages })) // Update the lang attribute in the root element. // (The lang is the first portion of the locale.) @@ -365,7 +376,7 @@ 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) { +export function setRouteViewerFilter(filter) { return async function (dispatch, getState) { dispatch(updateRouteViewerFilter(filter)) @@ -375,7 +386,9 @@ export function setRouteViewerFilter (filter) { if ( filter.agency && activeModeFilter && - !getModesForActiveAgencyFilter(getState()).includes(activeModeFilter.toUpperCase()) + !getModesForActiveAgencyFilter(getState()).includes( + activeModeFilter.toUpperCase() + ) ) { // If invalid mode is selected, reset mode dispatch(updateRouteViewerFilter({ mode: null })) diff --git a/lib/actions/user.js b/lib/actions/user.js index b5fbdad4e..335cbd0f3 100644 --- a/lib/actions/user.js +++ b/lib/actions/user.js @@ -1,12 +1,19 @@ import { createAction } from 'redux-actions' -import { OTP_API_DATE_FORMAT } from '@opentripplanner/core-utils/lib/time' -import { planParamsToQuery } from '@opentripplanner/core-utils/lib/query' import clone from 'clone' +import coreUtils from '@opentripplanner/core-utils' +import isEmpty from 'lodash.isempty' import moment from 'moment' import qs from 'qs' +import { + convertToPlace, + getPersistenceMode, + isHomeOrWork, + isNewUser, + positionHomeAndWorkFirst +} from '../util/user' +import { isBatchRoutingEnabled } from '../util/itinerary' import { isBlank } from '../util/ui' -import { isNewUser, positionHomeAndWorkFirst } from '../util/user' import { secureFetch } from '../util/middleware' import { TRIPS_PATH, URL_ROOT } from '../util/constants' @@ -14,21 +21,38 @@ import { routeTo } from './ui' import { routingQuery } from './api' import { setQueryParam } from './form' +const { planParamsToQuery } = coreUtils.query +const { OTP_API_DATE_FORMAT } = coreUtils.time + // Middleware API paths. const API_MONITORED_TRIP_PATH = '/api/secure/monitoredtrip' +const API_TRIP_HISTORY_PATH = '/api/secure/triprequests' const API_OTPUSER_PATH = '/api/secure/user' const API_OTPUSER_VERIFY_SMS_SUBPATH = '/verify_sms' +// Middleware user actions const setAccessToken = createAction('SET_ACCESS_TOKEN') const setCurrentUser = createAction('SET_CURRENT_USER') const setCurrentUserMonitoredTrips = createAction( 'SET_CURRENT_USER_MONITORED_TRIPS' ) +const setCurrentUserTripRequests = createAction( + 'SET_CURRENT_USER_TRIP_REQUESTS' +) const setLastPhoneSmsRequest = createAction('SET_LAST_PHONE_SMS_REQUEST') export const setPathBeforeSignIn = createAction('SET_PATH_BEFORE_SIGNIN') export const clearItineraryExistence = createAction('CLEAR_ITINERARY_EXISTENCE') const setitineraryExistence = createAction('SET_ITINERARY_EXISTENCE') +// localStorage user actions +export const deleteLocalUserRecentPlace = createAction( + 'DELETE_LOCAL_USER_RECENT_PLACE' +) +const deleteLocalUserSavedPlace = createAction('DELETE_LOCAL_USER_SAVED_PLACE') +export const forgetStop = createAction('FORGET_STOP') +export const rememberStop = createAction('REMEMBER_STOP') +const rememberLocalUserPlace = createAction('REMEMBER_LOCAL_USER_PLACE') + function createNewUser(auth0User) { return { accessibilityRoutingByDefault: false, @@ -109,6 +133,88 @@ export function fetchAuth0Token(auth0, intl) { } } +/** + * Converts a middleware trip request to the search format. + */ +function convertRequestToSearch(config) { + return function (tripRequest) { + const { dateCreated, id, requestParameters = {} } = tripRequest + const { host, path } = config.api + return { + canDelete: false, + id, + query: planParamsToQuery(requestParameters || {}), + timestamp: dateCreated, + url: `${host}${path}/plan?${qs.stringify(requestParameters)}` + } + } +} + +/** + * Removes duplicate requests saved from batch queries, + * so that only one request is displayed per batch. + * (Except for the mode, all query params in the same batch are the same.) + */ +function removeDuplicateRequestsFromBatch(config) { + if (isBatchRoutingEnabled(config)) { + const batches = {} + return function ({ query }) { + if (!batches[query.batchId]) { + batches[query.batchId] = true + + // Remove the mode for display purposes + query.mode = '' + return true + } + return false + } + } + return (tripReq) => tripReq +} + +/** + * Fetches the most recent (default 10) trip history for a user. + */ +export function fetchTripRequests() { + return async function (dispatch, getState) { + const { accessToken, apiBaseUrl, apiKey, loggedInUser } = + getMiddlewareVariables(getState()) + const requestUrl = `${apiBaseUrl}${API_TRIP_HISTORY_PATH}?userId=${loggedInUser.id}` + + const { data: requests, status: emptyStatus } = await secureFetch( + `${requestUrl}&limit=0`, + accessToken, + apiKey, + 'GET' + ) + + // FIXME: Revert to 10 after taking care of duplicates in the middleware. + const DEFAULT_LIMIT = 50 + + if (emptyStatus === 'success') { + const { data: trips, status } = await secureFetch( + `${requestUrl}&offset=${Math.max( + 0, + requests.total - DEFAULT_LIMIT + )}&limit=${DEFAULT_LIMIT}`, + accessToken, + apiKey, + 'GET' + ) + if (status === 'success') { + // Convert tripRequests to search format. + const { config } = getState().otp + const convertedTrips = trips.data + .map(convertRequestToSearch(config)) + .filter((tripReq) => !isEmpty(tripReq.query)) + .filter(removeDuplicateRequestsFromBatch(config)) + + dispatch(setCurrentUserTripRequests(convertedTrips)) + } + } + } +} + /** * Updates the redux state with the provided user data, including * placing the Home and Work locations at the beginning of the list @@ -130,6 +236,7 @@ function setUser(user, fetchTrips, intl) { if (fetchTrips) { dispatch(fetchMonitoredTrips()) + dispatch(fetchTripRequests()) } const { accessibilityRoutingByDefault } = user @@ -590,7 +697,6 @@ export function planNewTripFromMonitoredTrip(monitoredTrip) { export function saveUserPlace(placeToSave, placeIndex, intl) { return function (dispatch, getState) { const { loggedInUser } = getState().user - if (placeIndex === 'new') { loggedInUser.savedLocations.push(placeToSave) } else { @@ -604,14 +710,15 @@ export function saveUserPlace(placeToSave, placeIndex, intl) { /** * Delete the place data at the specified index for the logged-in user. */ -export function deleteUserPlace(placeIndex, intl) { +export function deleteLoggedInUserPlace(placeIndex, intl) { return function (dispatch, getState) { if ( !window.confirm( intl.formatMessage({ id: 'actions.user.confirmDeletePlace' }) ) - ) + ) { return + } const { loggedInUser } = getState().user loggedInUser.savedLocations.splice(placeIndex, 1) @@ -619,3 +726,61 @@ export function deleteUserPlace(placeIndex, intl) { dispatch(createOrUpdateUser(loggedInUser, true, intl)) } } + +/** + * Delete a place for the logged-in or local user according to the persistence strategy. + */ +export function deleteUserPlace(place, intl) { + return function (dispatch, getState) { + const { otp, user } = getState() + const persistenceMode = getPersistenceMode(otp.config.persistence) + const { loggedInUser } = user + + if (persistenceMode.isOtpMiddleware && loggedInUser) { + // Find the index of the place in the loggedInUser.savedLocations + + // If 'Forget home' or 'Forget work' links from OTP UI's EndPointOverlay/EndPoint + // are clicked, then place will set to 'home' or 'work'. + const placeIndex = + place === 'home' || place === 'work' + ? loggedInUser.savedLocations.findIndex((loc) => loc.type === place) + : loggedInUser.savedLocations.indexOf(place) + + if (placeIndex > -1) { + dispatch(deleteLoggedInUserPlace(placeIndex, intl)) + } + } else if (persistenceMode.isLocalStorage) { + dispatch(deleteLocalUserSavedPlace(place)) + } + } +} + +/** + * Remembers a place for the logged-in or local user + * according to the persistence strategy. + */ +export function rememberPlace(placeTypeLocation) { + return function (dispatch, getState) { + const { otp, user } = getState() + const persistenceMode = getPersistenceMode(otp.config.persistence) + const { loggedInUser } = user + + if (persistenceMode.isOtpMiddleware && loggedInUser) { + // For middleware loggedInUsers, this method should only be triggered by the + // 'Save as home' or 'Save as work' links from OTP UI's EndPointOverlay/EndPoint. + const { location } = placeTypeLocation + if (isHomeOrWork(location)) { + // Find the index of the place in the loggedInUser.savedLocations + const placeIndex = loggedInUser.savedLocations.findIndex( + (loc) => loc.type === location.type + ) + if (placeIndex > -1) { + // Convert to loggedInUser saved place + dispatch(saveUserPlace(convertToPlace(location), placeIndex)) + } + } + } else if (persistenceMode.isLocalStorage) { + dispatch(rememberLocalUserPlace(placeTypeLocation)) + } + } +} diff --git a/lib/components/admin/call-taker.css b/lib/components/admin/call-taker.css index 917ec1eb4..fc2d511ce 100644 --- a/lib/components/admin/call-taker.css +++ b/lib/components/admin/call-taker.css @@ -20,3 +20,7 @@ margin-bottom: 5px; height: 24px; } + +.otp .search-plan-button-container { + padding: 1rem; +} \ No newline at end of file diff --git a/lib/components/app/app-menu.tsx b/lib/components/app/app-menu.tsx index bbec60447..c87eae577 100644 --- a/lib/components/app/app-menu.tsx +++ b/lib/components/app/app-menu.tsx @@ -8,7 +8,8 @@ import { MenuItem } from 'react-bootstrap' import { withRouter } from 'react-router' import qs from 'qs' import SlidingPane from 'react-sliding-pane' -import type { InjectedIntlProps } from 'react-intl' +import type { RouteComponentProps } from 'react-router' +import type { WrappedComponentProps } from 'react-intl' // No types available, old package // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -21,9 +22,16 @@ import { MainPanelContent, setMainPanelContent } from '../../actions/ui' import Icon from '../util/icon' type AppMenuProps = { + callTakerEnabled?: boolean + extraMenuItems?: menuItem[] + fieldTripEnabled?: boolean location: { search: string } - reactRouterConfig: { basename: string } + mailablesEnabled?: boolean + reactRouterConfig?: { basename: string } + resetAndToggleCallHistory?: () => void + resetAndToggleFieldTrips?: () => void setMainPanelContent: (panel: number) => void + toggleMailables: () => void } type AppMenuState = { expandedSubmenus: Record @@ -43,7 +51,7 @@ type menuItem = { * Sidebar which appears to show user list of options and links */ class AppMenu extends Component< - AppMenuProps & InjectedIntlProps, + AppMenuProps & WrappedComponentProps & RouteComponentProps, AppMenuState > { _showRouteViewer = () => { @@ -85,7 +93,7 @@ class AppMenu extends Component< this.setState({ expandedSubmenus: { [id]: !currentlyOpen } }) } - _addExtraMenuItems = (menuItems: menuItem[]) => { + _addExtraMenuItems = (menuItems?: menuItem[]) => { return ( menuItems && menuItems.map((menuItem) => { @@ -260,8 +268,8 @@ const mapDispatchToProps = { toggleMailables: callTakerActions.toggleMailables } -export default withRouter( - connect(mapStateToProps, mapDispatchToProps)(injectIntl(AppMenu)) +export default injectIntl( + withRouter(connect(mapStateToProps, mapDispatchToProps)(AppMenu)) ) /** diff --git a/lib/components/app/app.css b/lib/components/app/app.css index d2f94b2d9..9e9e642a8 100644 --- a/lib/components/app/app.css +++ b/lib/components/app/app.css @@ -62,10 +62,6 @@ box-sizing: border-box; } -/* Batch routing panel requires padding removed from sidebar */ -.batch-routing-panel { - padding: 10px; -} /* View Switcher Styling */ .view-switcher { align-items: center; diff --git a/lib/components/app/batch-routing-panel.js b/lib/components/app/batch-routing-panel.js index 6e6987d12..673f8f322 100644 --- a/lib/components/app/batch-routing-panel.js +++ b/lib/components/app/batch-routing-panel.js @@ -1,108 +1,79 @@ -import React, { Component } from 'react' -import { injectIntl } from 'react-intl' +/* eslint-disable react/prop-types */ import { connect } from 'react-redux' -import styled from 'styled-components' +import { injectIntl } from 'react-intl' +import React, { Component } from 'react' -import * as apiActions from '../../actions/api' -import * as formActions from '../../actions/form' +import { getActiveSearch, getShowUserSettings } from '../../util/state' import BatchSettings from '../form/batch-settings' import LocationField from '../form/connected-location-field' import NarrativeItineraries from '../narrative/narrative-itineraries' -import { getActiveSearch, getShowUserSettings } from '../../util/state' -import ViewerContainer from '../viewers/viewer-container' import SwitchButton from '../form/switch-button' - -// Style for setting the top of the narrative itineraries based on the width of the window. -// If the window width is less than 1200px (Bootstrap's "large" size), the -// mode buttons will be shown on their own row, meaning that the -// top position of this component needs to be lower (higher value -// equals lower position on the page). -// TODO: figure out a better way to use flex rendering for accommodating the mode button overflow. -const NarrativeContainer = styled.div` - & .options.itinerary { - @media (min-width: 1200px) { - top: 160px; - } - @media (max-width: 1199px) { - top: 210px; - } - } -` +import UserSettings from '../form/user-settings' +import ViewerContainer from '../viewers/viewer-container' /** * Main panel for the batch/trip comparison form. */ class BatchRoutingPanel extends Component { - render () { - const { intl, mobile } = this.props + render() { + const { activeSearch, intl, mobile, showUserSettings } = this.props return ( - -
+ +
-
- } /> +
+ } + />
+ +
+ {!activeSearch && showUserSettings && ( + + )} +
+
- - {/* FIXME: Add back user settings (home, work, etc.) once connected to - the middleware persistence. - !activeSearch && showUserSettings && - - */} - - - ) } } // connect to the redux store -const mapStateToProps = (state, ownProps) => { +const mapStateToProps = (state) => { const showUserSettings = getShowUserSettings(state) return { activeSearch: getActiveSearch(state), - config: state.otp.config, - currentQuery: state.otp.currentQuery, - expandAdvanced: state.otp.user.expandAdvanced, - possibleCombinations: state.otp.config.modes.combinations, showUserSettings } } -const mapDispatchToProps = { - routingQuery: apiActions.routingQuery, - setQueryParam: formActions.setQueryParam -} - -export default connect(mapStateToProps, mapDispatchToProps)( - injectIntl(BatchRoutingPanel) -) +export default connect(mapStateToProps)(injectIntl(BatchRoutingPanel)) diff --git a/lib/components/app/call-taker-panel.js b/lib/components/app/call-taker-panel.js index cd196bc2f..854200f4a 100644 --- a/lib/components/app/call-taker-panel.js +++ b/lib/components/app/call-taker-panel.js @@ -4,7 +4,6 @@ import { Button } from 'react-bootstrap' import { connect } from 'react-redux' import { FormattedMessage, injectIntl } from 'react-intl' import { getTimeFormat } from '@opentripplanner/core-utils/lib/time' -import { storeItem } from '@opentripplanner/core-utils/lib/storage' import React, { Component } from 'react' import styled from 'styled-components' @@ -15,6 +14,7 @@ import * as formActions from '../../actions/form' import { getActiveSearch, getShowUserSettings, + getSortedFilteredRoutes, hasValidLocation } from '../../util/state' import { getGroupSize } from '../../util/call-taker' @@ -92,8 +92,6 @@ class CallTakerPanel extends Component { _onHideAdvancedClick = () => { const expandAdvanced = !this.state.expandAdvanced - // FIXME move logic to action - storeItem('expandAdvanced', expandAdvanced) this.setState({ expandAdvanced }) } @@ -151,149 +149,142 @@ class CallTakerPanel extends Component { zIndex: 99999 } return ( - - {/* FIXME: should this be a styled component */} -
-
- +
+ + {Array.isArray(intermediatePlaces) && + intermediatePlaces.map((place, i) => { + return ( + this._addPlace(result, i)} + showClearButton={!mobile} + /> + ) + })} + +
+ } /> - {Array.isArray(intermediatePlaces) && - intermediatePlaces.map((place, i) => { - return ( - this._addPlace(result, i)} - showClearButton={!mobile} - /> - ) - })} - + +
+ -
- } - /> -
- { + this.setState({ transitModes }) + }} /> -
- - +
+ +
+
+ {groupSize !== null && maxGroupSize && ( + + + + + )} + +
+ { - this.setState({ transitModes }) - }} + routes={routes} + setQueryParam={setQueryParam} /> - -
-
- {groupSize !== null && maxGroupSize && ( - - - - - )} - -
- -
- {!activeSearch && !showPlanTripButton && showUserSettings && ( - - )} -
- -
+
+ {!activeSearch && !showPlanTripButton && showUserSettings && ( + + )} +
+
) @@ -308,12 +299,11 @@ const mapStateToProps = (state) => { return { activeSearch: getActiveSearch(state), currentQuery: state.otp.currentQuery, - expandAdvanced: state.otp.user.expandAdvanced, groupSize: state.callTaker.fieldTrip.groupSize, mainPanelContent: state.otp.ui.mainPanelContent, maxGroupSize: getGroupSize(request), modes: state.otp.config.modes, - routes: state.otp.transitIndex.routes, + routes: getSortedFilteredRoutes(state), showUserSettings, timeFormat: getTimeFormat(state.otp.config) } diff --git a/lib/components/app/desktop-nav.js b/lib/components/app/desktop-nav.tsx similarity index 60% rename from lib/components/app/desktop-nav.js rename to lib/components/app/desktop-nav.tsx index 74e3c587a..e5c60106d 100644 --- a/lib/components/app/desktop-nav.js +++ b/lib/components/app/desktop-nav.tsx @@ -1,14 +1,14 @@ -import React from 'react' -import { Nav, Navbar } from 'react-bootstrap' import { connect } from 'react-redux' +import { Nav, Navbar } from 'react-bootstrap' +import React from 'react' -import NavLoginButtonAuth0 from '../user/nav-login-button-auth0.js' import { accountLinks, getAuth0Config } from '../../util/auth' import { DEFAULT_APP_TITLE } from '../../util/constants' +import NavLoginButtonAuth0 from '../user/nav-login-button-auth0' import AppMenu from './app-menu' +import LocaleSelector from './locale-selector' import ViewSwitcher from './view-switcher' - /** * The desktop navigation bar, featuring a `branding` logo or a `title` text * defined in config.yml, and a sign-in button/menu with account links. @@ -21,8 +21,14 @@ import ViewSwitcher from './view-switcher' * * TODO: merge with the mobile navigation bar. */ -const DesktopNav = ({ otpConfig }) => { +// Typscript TODO: otpConfig type +export type otpConfigType = { + otpConfig: any +} + +const DesktopNav = ({ otpConfig }: otpConfigType) => { const { branding, persistence, title = DEFAULT_APP_TITLE } = otpConfig + const { language: configLanguages } = otpConfig const showLogin = Boolean(getAuth0Config(persistence)) // Display branding and title in the order as described in the class summary. @@ -37,49 +43,56 @@ const DesktopNav = ({ otpConfig }) => { ) } else { brandingOrTitle = ( -
{title}
+
+ {title} +
) } return ( {/* Required to allow the hamburger button to be clicked */} - + {/* TODO: Reconcile CSS class and inline style. */} -
+
{brandingOrTitle} - - {showLogin && ( - - - - )} + + + ) } // connect to the redux store - -const mapStateToProps = (state, ownProps) => { +// Typescript TODO: state type +const mapStateToProps = (state: any) => { return { otpConfig: state.otp.config } } -const mapDispatchToProps = { -} +const mapDispatchToProps = {} export default connect(mapStateToProps, mapDispatchToProps)(DesktopNav) diff --git a/lib/components/app/locale-selector.tsx b/lib/components/app/locale-selector.tsx new file mode 100644 index 000000000..b16bff508 --- /dev/null +++ b/lib/components/app/locale-selector.tsx @@ -0,0 +1,87 @@ +import { connect, ConnectedProps } from 'react-redux' +import { MenuItem, NavDropdown } from 'react-bootstrap' +import { useIntl } from 'react-intl' +import React, { MouseEvent } from 'react' + +import * as uiActions from '../../actions/ui' +import * as userActions from '../../actions/user' +import Icon from '../util/icon' + +type PropsFromRedux = ConnectedProps + +interface LocaleSelectorProps extends PropsFromRedux { + // Typescript TODO configLanguageType + configLanguages: Record +} + +const LocaleSelector = (props: LocaleSelectorProps): JSX.Element => { + const { + configLanguages, + createOrUpdateUser, + locale: currentLocale, + loggedInUser, + setLocale + } = props + + const intl = useIntl() + + const handleLocaleSelection = (e: MouseEvent, locale: string) => { + e.stopPropagation() + if (locale === currentLocale) { + e.preventDefault() + return + } + window.localStorage.setItem('lang', locale) + + if (loggedInUser) { + loggedInUser.preferredLanguage = locale + createOrUpdateUser(loggedInUser, false, intl) + } + setLocale(locale) + + document.location.reload() + } + + return ( + + } + > + {Object.keys(configLanguages).map((locale) => { + return ( + locale !== 'allLanguages' && ( + handleLocaleSelection(e, locale)} + > + + {configLanguages[locale].name} + + + ) + ) + })} + + ) +} + +// Typescript TODO: type state properly +const mapStateToProps = (state: any) => { + return { + locale: state.otp.ui.locale, + loggedInUser: state.user.loggedInUser + } +} + +const mapDispatchToProps = { + createOrUpdateUser: userActions.createOrUpdateUser, + setLocale: uiActions.setLocale +} + +const connector = connect(mapStateToProps, mapDispatchToProps) +export default connector(LocaleSelector) diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js index f374558ff..d0150dbfd 100644 --- a/lib/components/app/responsive-webapp.js +++ b/lib/components/app/responsive-webapp.js @@ -390,7 +390,7 @@ const mapStateToWrapperProps = (state) => { const { persistence, reactRouter } = state.otp.config return { auth0Config: getAuth0Config(persistence), - defaultLocale: getDefaultLocale(state.otp.config), + defaultLocale: getDefaultLocale(state.otp.config, state.user.loggedInUser), locale: state.otp.ui.locale, localizedMessages: state.otp.ui.localizedMessages, routerConfig: reactRouter diff --git a/lib/components/form/batch-preferences.js b/lib/components/form/batch-preferences.tsx similarity index 55% rename from lib/components/form/batch-preferences.js rename to lib/components/form/batch-preferences.tsx index f6e37501b..90c4fe1a0 100644 --- a/lib/components/form/batch-preferences.js +++ b/lib/components/form/batch-preferences.tsx @@ -1,34 +1,66 @@ +// Typescript TODO: these types are a bit useless without types for config, query, and queryparam +/* eslint-disable @typescript-eslint/no-explicit-any */ // import {DropdownSelector} from '@opentripplanner/trip-form' -import React, { Component } from 'react' import { connect } from 'react-redux' +import React, { Component } from 'react' -import { setQueryParam } from '../../actions/form' import { ComponentContext } from '../../util/contexts' -import { getShowUserSettings } from '../../util/state' +import { setQueryParam } from '../../actions/form' import { StyledBatchPreferences } from './batch-styled' -import UserTripSettings from './user-trip-settings' -class BatchPreferences extends Component { +// TODO: Central type source +export type Combination = { + mode: string + params?: { [key: string]: number | string } +} + +export const replaceTransitMode = + (newQueryParamsMode: string) => + (combination: Combination): Combination => { + // Split out walk so it's not duplicated + const newMode = (newQueryParamsMode || 'WALK,TRANSIT').split('WALK,')?.[1] + // Replace TRANSIT with the newly selected parameters + const mode = combination.mode.replace('TRANSIT', newMode) + return { ...combination, mode } + } + +class BatchPreferences extends Component<{ + config: any + query: any + setQueryParam: (newQueryParam: any) => void +}> { static contextType = ComponentContext - render () { - const { - config, - query, - setQueryParam, - showUserSettings - } = this.props + /** + * When the queryParam changes, the mode is correctly updated but the + * active combinations are not. This method updates the currentQuery combinations + * and ensures that they all contain the correct modes + * + * Typescript TODO: combinations and queryParams need types + */ + onQueryParamChange = (newQueryParams: any) => { + const { config, query, setQueryParam } = this.props + const disabledModes = query.disabledModes || [] + const combinations = config.modes.combinations + .filter((combination: Combination) => { + const modesInCombination = combination.mode.split(',') + return !modesInCombination.find((m) => disabledModes.includes(m)) + }) + .map(replaceTransitMode(newQueryParams.mode)) + setQueryParam({ ...newQueryParams, combinations }) + } + + render() { + const { config, query } = this.props const { ModeIcon } = this.context return ( -
-
- {showUserSettings && } - +
+
{ +const mapStateToProps = (state: { + otp: { config: any; currentQuery: any } +}) => { const { config, currentQuery } = state.otp return { config, - query: currentQuery, - showUserSettings: getShowUserSettings(state) + query: currentQuery } } diff --git a/lib/components/form/batch-settings.js b/lib/components/form/batch-settings.tsx similarity index 57% rename from lib/components/form/batch-settings.js rename to lib/components/form/batch-settings.tsx index b3087b173..20f87bbd9 100644 --- a/lib/components/form/batch-settings.js +++ b/lib/components/form/batch-settings.tsx @@ -1,17 +1,18 @@ +/* eslint-disable react/prop-types */ +import { connect } from 'react-redux' +import { injectIntl, IntlShape } from 'react-intl' +// FIXME: type OTP-UI +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import coreUtils from '@opentripplanner/core-utils' import React, { Component } from 'react' -import { injectIntl } from 'react-intl' -import { connect } from 'react-redux' import styled from 'styled-components' import * as apiActions from '../../actions/api' import * as formActions from '../../actions/form' +import { hasValidLocation } from '../../util/state' import Icon from '../util/icon' -import { hasValidLocation, getActiveSearch, getShowUserSettings } from '../../util/state' -import BatchPreferences from './batch-preferences' -import DateTimeModal from './date-time-modal' -import ModeButtons, {getModeOptions, StyledModeButton} from './mode-buttons' import { BatchPreferencesContainer, DateTimeModalContainer, @@ -21,6 +22,10 @@ import { StyledDateTimePreview } from './batch-styled' import { Dot } from './styled' +import BatchPreferences, { replaceTransitMode } from './batch-preferences' +import DateTimeModal from './date-time-modal' +import ModeButtons, { getModeOptions, StyledModeButton } from './mode-buttons' +import type { Combination } from './batch-preferences' /** * Simple utility to check whether a list of mode strings contains the provided @@ -31,12 +36,12 @@ import { Dot } from './styled' * the 'contains' check. E.g., we might not want to remove WALK,TRANSIT if walk * is turned off, but we DO want to remove it if TRANSIT is turned off. */ -function listHasMode (modes, mode) { - return modes.some(m => mode.indexOf(m) !== -1) +function listHasMode(modes: string[], mode: string) { + return modes.some((m: string) => mode.indexOf(m) !== -1) } -function combinationHasAnyOfModes (combination, modes) { - return combination.mode.split(',').some(m => listHasMode(modes, m)) +function combinationHasAnyOfModes(combination: Combination, modes: string[]) { + return combination.mode.split(',').some((m: string) => listHasMode(modes, m)) } const ModeButtonsFullWidthContainer = styled.div` @@ -75,71 +80,86 @@ const ModeButtonsCompressed = styled(ModeButtons)` /** * Main panel for the batch/trip comparison form. */ -class BatchSettings extends Component { +// TYPESCRIPT TODO: better types +class BatchSettings extends Component<{ + config: any + currentQuery: any + intl: IntlShape + possibleCombinations: Combination[] + routingQuery: any + setQueryParam: (queryParam: any) => void +}> { state = { expanded: null, - selectedModes: getModeOptions(this.props.intl).map(m => m.mode) + selectedModes: getModeOptions(this.props.intl).map((m) => m.mode) } - _onClickMode = (mode) => { - const {possibleCombinations, setQueryParam} = this.props - const {selectedModes} = this.state + _onClickMode = (mode: string) => { + const { currentQuery, possibleCombinations, setQueryParam } = this.props + const { selectedModes } = this.state const index = selectedModes.indexOf(mode) const enableMode = index === -1 const newModes = [...selectedModes] if (enableMode) newModes.push(mode) else newModes.splice(index, 1) // Update selected modes for mode buttons. - this.setState({selectedModes: newModes}) + this.setState({ selectedModes: newModes }) // Update the available mode combinations based on the new modes selection. - const possibleModes = getModeOptions(this.props.intl).map(m => m.mode) - const disabledModes = possibleModes.filter(m => !newModes.includes(m)) + const possibleModes = getModeOptions(this.props.intl).map((m) => m.mode) + const disabledModes = possibleModes.filter((m) => !newModes.includes(m)) // Do not include combination if any of its modes are found in disabled // modes list. const newCombinations = possibleCombinations - .filter(c => !combinationHasAnyOfModes(c, disabledModes)) - setQueryParam({combinations: newCombinations}) + .filter((c) => !combinationHasAnyOfModes(c, disabledModes)) + .map(replaceTransitMode(currentQuery.mode)) + setQueryParam({ combinations: newCombinations, disabledModes }) } _planTrip = () => { - const {currentQuery, intl, routingQuery} = this.props + const { currentQuery, intl, routingQuery } = this.props // Check for any validation issues in query. const issues = [] if (!hasValidLocation(currentQuery, 'from')) { - issues.push(intl.formatMessage({id: 'components.BatchSettings.origin'})) + issues.push(intl.formatMessage({ id: 'components.BatchSettings.origin' })) } if (!hasValidLocation(currentQuery, 'to')) { - issues.push(intl.formatMessage({id: 'components.BatchSettings.destination'})) + issues.push( + intl.formatMessage({ id: 'components.BatchSettings.destination' }) + ) } if (issues.length > 0) { // TODO: replace with less obtrusive validation. - window.alert(intl.formatMessage( - {id: 'components.BatchSettings.validationMessage'}, - {issues: intl.formatList(issues, {type: 'conjunction'})} - )) + window.alert( + intl.formatMessage( + { id: 'components.BatchSettings.validationMessage' }, + { issues: intl.formatList(issues, { type: 'conjunction' }) } + ) + ) return } // Close any expanded panels. - this.setState({expanded: null}) + this.setState({ expanded: null }) // Plan trip. routingQuery() } - _updateExpanded = (type) => ({expanded: this.state.expanded === type ? null : type}) + _updateExpanded = (type: string) => ({ + expanded: this.state.expanded === type ? null : type + }) _toggleDateTime = () => this.setState(this._updateExpanded('DATE_TIME')) _toggleSettings = () => this.setState(this._updateExpanded('SETTINGS')) - render () { - const {config, currentQuery, intl} = this.props - const {expanded, selectedModes} = this.state + render() { + const { config, currentQuery, intl } = this.props + const { expanded, selectedModes } = this.state return ( <> - + @@ -149,63 +169,62 @@ class BatchSettings extends Component { expanded={expanded === 'SETTINGS'} onClick={this._toggleSettings} > - {coreUtils.query.isNotDefaultQuery(currentQuery, config) && - - } - + {coreUtils.query.isNotDefaultQuery(currentQuery, config) && ( + + )} + + onClick={this._toggleDateTime} + /> - + - {expanded === 'DATE_TIME' && + {expanded === 'DATE_TIME' && ( - } - {expanded === 'SETTINGS' && + )} + {expanded === 'SETTINGS' && ( - } + )} ) } } // connect to the redux store -const mapStateToProps = (state, ownProps) => { - const showUserSettings = getShowUserSettings(state) - return { - activeSearch: getActiveSearch(state), - config: state.otp.config, - currentQuery: state.otp.currentQuery, - expandAdvanced: state.otp.user.expandAdvanced, - possibleCombinations: state.otp.config.modes.combinations, - showUserSettings - } -} +// TODO: Typescript +const mapStateToProps = (state: any) => ({ + config: state.otp.config, + currentQuery: state.otp.currentQuery, + possibleCombinations: state.otp.config.modes.combinations +}) const mapDispatchToProps = { routingQuery: apiActions.routingQuery, setQueryParam: formActions.setQueryParam } -export default connect(mapStateToProps, mapDispatchToProps)( - injectIntl(BatchSettings) -) +export default connect( + mapStateToProps, + mapDispatchToProps +)(injectIntl(BatchSettings)) diff --git a/lib/components/form/batch-styled.js b/lib/components/form/batch-styled.ts similarity index 89% rename from lib/components/form/batch-styled.js rename to lib/components/form/batch-styled.ts index 7b4bdda86..70dfaa24e 100644 --- a/lib/components/form/batch-styled.js +++ b/lib/components/form/batch-styled.ts @@ -1,18 +1,18 @@ import * as TripFormClasses from '@opentripplanner/trip-form/lib/styled' import { SettingsSelectorPanel } from '@opentripplanner/trip-form' -import styled, {css} from 'styled-components' +import styled, { css } from 'styled-components' +import { commonInputCss, modeButtonButtonCss } from './styled' import DateTimePreview from './date-time-preview' -import {commonInputCss, modeButtonButtonCss} from './styled' const SHADOW = 'inset 0px 0px 5px #c1c1c1' const activeCss = css` background: #e5e5e5; -webkit-box-shadow: ${SHADOW}; - -moz-box-shadow: ${SHADOW}; - box-shadow: ${SHADOW}; - outline: none; + -moz-box-shadow: ${SHADOW}; + box-shadow: ${SHADOW}; + outline: none; ` export const buttonCss = css` @@ -40,18 +40,18 @@ export const StyledDateTimePreview = styled(DateTimePreview)` text-align: left; white-space: nowrap; width: 120px; - ${props => props.expanded ? activeCss : null} + ${(props) => (props.expanded ? activeCss : null)} ` -export const SettingsPreview = styled(Button)` +export const SettingsPreview = styled(Button)<{ expanded?: boolean }>` line-height: 22px; margin-right: 5px; padding: 10px 0px; position: relative; - ${props => props.expanded ? activeCss : null} + ${(props) => (props.expanded ? activeCss : null)} ` export const PlanTripButton = styled(Button)` - background-color: #F5F5A7; + background-color: #f5f5a7; margin-left: auto; padding: 5px; &:active { @@ -122,14 +122,15 @@ export const StyledBatchPreferences = styled(SettingsSelectorPanel)` &:focus { border-color: #66afe9; - box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), + 0 0 8px rgba(102, 175, 233, 0.6); outline: 0; } } > div:last-child::after { box-sizing: border-box; color: #000; - content: "▼"; + content: '▼'; font-size: 67%; pointer-events: none; position: absolute; @@ -176,7 +177,7 @@ export const StyledBatchPreferences = styled(SettingsSelectorPanel)` font-weight: 800; height: 46px; > svg { - margin: 0 0.20em; + margin: 0 0.2em; } } } diff --git a/lib/components/form/call-taker/advanced-options.js b/lib/components/form/call-taker/advanced-options.js index d74d809d6..0bbb19757 100644 --- a/lib/components/form/call-taker/advanced-options.js +++ b/lib/components/form/call-taker/advanced-options.js @@ -1,15 +1,17 @@ +/* eslint-disable react/prop-types */ // FIXME: Remove the following eslint rule exception. /* eslint-disable jsx-a11y/label-has-for */ -import { hasBike } from '@opentripplanner/core-utils/lib/itinerary' -import {SubmodeSelector} from '@opentripplanner/trip-form' import * as TripFormClasses from '@opentripplanner/trip-form/lib/styled' -import React, { Component } from 'react' import { FormattedMessage, injectIntl } from 'react-intl' +import { hasBike } from '@opentripplanner/core-utils/lib/itinerary' +import { SubmodeSelector } from '@opentripplanner/trip-form' +import isEmpty from 'lodash.isempty' +import React, { Component } from 'react' import Select from 'react-select' import styled from 'styled-components' -import { modeButtonButtonCss } from '../styled' import { ComponentContext } from '../../../util/contexts' +import { modeButtonButtonCss } from '../styled' export const StyledSubmodeSelector = styled(SubmodeSelector)` ${TripFormClasses.SubmodeSelector.Row} { @@ -32,73 +34,75 @@ export const StyledSubmodeSelector = styled(SubmodeSelector)` } ${TripFormClasses.SubmodeSelector} { - ${modeButtonButtonCss} + ${modeButtonButtonCss} } ` -const metersToMiles = meters => Math.round(meters * 0.000621371 * 100) / 100 -const milesToMeters = miles => miles / 0.000621371 +const metersToMiles = (meters) => Math.round(meters * 0.000621371 * 100) / 100 +const milesToMeters = (miles) => miles / 0.000621371 class AdvancedOptions extends Component { - constructor (props) { + constructor(props) { super(props) this.state = { expandAdvanced: props.expandAdvanced, routeOptions: [], - transitModes: props.modes.transitModes.map(m => m.mode) + transitModes: props.modes.transitModes.map((m) => m.mode) } } static contextType = ComponentContext - componentDidMount () { + componentDidMount() { // Fetch routes for banned/preferred routes selectors. this.props.findRoutes() } - componentDidUpdate (prevProps) { - const {routes} = this.props + componentDidUpdate(prevProps) { + const { routes } = this.props // Once routes are available, map them to the route options format. - if (routes && !prevProps.routes) { + if (!isEmpty(routes) && isEmpty(prevProps.routes)) { const routeOptions = Object.values(routes).map(this.routeToOption) - this.setState({routeOptions}) + this.setState({ routeOptions }) } } - _setBannedRoutes = options => { - const bannedRoutes = options ? options.map(o => o.value).join(',') : '' + _setBannedRoutes = (options) => { + const bannedRoutes = options ? options.map((o) => o.value).join(',') : '' this.props.setQueryParam({ bannedRoutes }) } - _setPreferredRoutes = options => { - const preferredRoutes = options ? options.map(o => (o.value)).join(',') : '' + _setPreferredRoutes = (options) => { + const preferredRoutes = options ? options.map((o) => o.value).join(',') : '' this.props.setQueryParam({ preferredRoutes }) } - _isBannedRouteOptionDisabled = option => { + _isBannedRouteOptionDisabled = (option) => { // Disable routes that are preferred already. const preferredRoutes = this.getRouteList('preferredRoutes') - return preferredRoutes && preferredRoutes.find(o => o.value === option.value) + return ( + preferredRoutes && preferredRoutes.find((o) => o.value === option.value) + ) } - _isPreferredRouteOptionDisabled = option => { + _isPreferredRouteOptionDisabled = (option) => { // Disable routes that are banned already. const bannedRoutes = this.getRouteList('bannedRoutes') - return bannedRoutes && bannedRoutes.find(o => o.value === option.value) + return bannedRoutes && bannedRoutes.find((o) => o.value === option.value) } - getDistanceStep = distanceInMeters => { + getDistanceStep = (distanceInMeters) => { // Determine step for max walk/bike based on current value. Increment by a // quarter mile if dealing with small values, whatever number will round off // the number if it is not an integer, or default to one mile. return metersToMiles(distanceInMeters) <= 2 ? '.25' : metersToMiles(distanceInMeters) % 1 !== 0 - ? `${metersToMiles(distanceInMeters) % 1}` - : '1' + ? `${metersToMiles(distanceInMeters) % 1}` + : '1' } - _onSubModeChange = changedMode => { + _onSubModeChange = (changedMode) => { // Get previous transit modes from state and all modes from query. const transitModes = [...this.state.transitModes] const allModes = this.props.currentQuery.mode.split(',') @@ -114,33 +118,37 @@ class AdvancedOptions extends Component { allModes.splice(i, 1) } // Update transit modes in state. - this.setState({transitModes}) + this.setState({ transitModes }) // Update all modes in query (set to walk if all transit modes inactive). this.props.setQueryParam({ mode: allModes.join(',') || 'WALK' }) } - _setMaxWalkDistance = evt => { - this.props.setQueryParam({ maxWalkDistance: milesToMeters(evt.target.value) }) + _setMaxWalkDistance = (evt) => { + this.props.setQueryParam({ + maxWalkDistance: milesToMeters(evt.target.value) + }) } /** * Get list of routes for specified key (either 'bannedRoutes' or * 'preferredRoutes'). */ - getRouteList = key => { + getRouteList = (key) => { const routesParam = this.props.currentQuery[key] const idList = routesParam ? routesParam.split(',') : [] if (this.state.routeOptions) { - return this.state.routeOptions.filter(o => idList.indexOf(o.value) !== -1) + return this.state.routeOptions.filter( + (o) => idList.indexOf(o.value) !== -1 + ) } else { // If route list is not available, default labels to route IDs. - return idList.map(id => ({label: id, value: id})) + return idList.map((id) => ({ label: id, value: id })) } } - routeToOption = route => { + routeToOption = (route) => { if (!route) return null - const {id, longName, shortName} = route + const { id, longName, shortName } = route // For some reason the OTP API expects route IDs in this double // underscore format // FIXME: This replace is flimsy! What if there are more colons? @@ -148,17 +156,17 @@ class AdvancedOptions extends Component { const label = shortName ? `${shortName}${longName ? ` - ${longName}` : ''}` : longName - return {label, value} + return { label, value } } - render () { + render() { const { currentQuery, intl, modes, onKeyDown } = this.props const { ModeIcon } = this.context - const {maxBikeDistance, maxWalkDistance, mode} = currentQuery + const { maxBikeDistance, maxWalkDistance, mode } = currentQuery const bannedRoutes = this.getRouteList('bannedRoutes') const preferredRoutes = this.getRouteList('preferredRoutes') - const transitModes = modes.transitModes.map(modeObj => { + const transitModes = modes.transitModes.map((modeObj) => { const modeStr = modeObj.mode || modeObj return { id: modeStr, @@ -171,32 +179,45 @@ class AdvancedOptions extends Component { const unitsString = '(mi.)' return (
-
-
) } diff --git a/lib/components/form/call-taker/date-time-options.js b/lib/components/form/call-taker/date-time-options.js index 4a469dee5..c4dba40ef 100644 --- a/lib/components/form/call-taker/date-time-options.js +++ b/lib/components/form/call-taker/date-time-options.js @@ -1,27 +1,30 @@ +/* eslint-disable react/prop-types */ +import { injectIntl } from 'react-intl' import { OTP_API_DATE_FORMAT, OTP_API_TIME_FORMAT } from '@opentripplanner/core-utils/lib/time' +import { OverlayTrigger, Tooltip } from 'react-bootstrap' import moment from 'moment' import React, { Component } from 'react' -import { OverlayTrigger, Tooltip } from 'react-bootstrap' -import { injectIntl } from 'react-intl' -const departureOptions = [ - { - // Default option. - childMessageId: 'components.DateTimeOptions.now', - value: 'NOW' - }, - { - childMessageId: 'components.DateTimeOptions.departAt', - value: 'DEPART' - }, - { - childMessageId: 'components.DateTimeOptions.arriveBy', - value: 'ARRIVE' - } -] +function getDepartureOptions(intl) { + return [ + { + // Default option. + text: intl.formatMessage({ id: 'components.DateTimeOptions.now' }), + value: 'NOW' + }, + { + text: intl.formatMessage({ id: 'components.DateTimeOptions.departAt' }), + value: 'DEPART' + }, + { + text: intl.formatMessage({ id: 'components.DateTimeOptions.arriveBy' }), + value: 'ARRIVE' + } + ] +} /** * Time formats passed to moment.js used to parse the user's time input. @@ -38,14 +41,14 @@ const SUPPORTED_TIME_FORMATS = [ 'ha', 'h', 'HH:mm' -].map(format => `YYYY-MM-DDT${format}`) +].map((format) => `YYYY-MM-DDT${format}`) /** * Convert input moment object to date/time query params in OTP API format. * @param {[type]} [time=moment(] [description] * @return {[type]} [description] */ -function momentToQueryParams (time = moment()) { +function momentToQueryParams(time = moment()) { return { date: time.format(OTP_API_DATE_FORMAT), time: time.format(OTP_API_TIME_FORMAT) @@ -70,18 +73,18 @@ class DateTimeOptions extends Component { timeInput: '' } - componentDidMount () { + componentDidMount() { if (this.props.departArrive === 'NOW') { this._startAutoRefresh() } } - componentWillUnmount () { + componentWillUnmount() { this._stopAutoRefresh() } - componentDidUpdate (prevProps) { - const {date, departArrive, time} = this.props + componentDidUpdate(prevProps) { + const { date, departArrive, time } = this.props const dateTime = this.dateTimeAsMoment() const parsedTime = this.parseInputAsTime(this.state.timeInput, date) // Update time input if time changes and the parsed time does not match what @@ -100,7 +103,7 @@ class DateTimeOptions extends Component { _updateTimeInput = (time = moment()) => // If auto-updating time input (for leave now), use short 24-hr format to // avoid writing a value that is too long for the time input's width. - this.setState({timeInput: time.format('H:mm')}) + this.setState({ timeInput: time.format('H:mm') }) _startAutoRefresh = () => { const timer = window.setInterval(this._refreshDateTime, 1000) @@ -109,7 +112,7 @@ class DateTimeOptions extends Component { _stopAutoRefresh = () => { window.clearInterval(this.state.timer) - this.setState({timer: null}) + this.setState({ timer: null }) } _refreshDateTime = () => { @@ -122,8 +125,8 @@ class DateTimeOptions extends Component { } } - _setDepartArrive = evt => { - const {value: departArrive} = evt.target + _setDepartArrive = (evt) => { + const { value: departArrive } = evt.target if (departArrive === 'NOW') { const now = moment() // If setting to leave now, update date/time and start auto refresh to keep @@ -142,14 +145,15 @@ class DateTimeOptions extends Component { } } - handleDateChange = evt => this.handleDateTimeChange({ date: evt.target.value }) + handleDateChange = (evt) => + this.handleDateTimeChange({ date: evt.target.value }) /** * Handler that should be used when date or time is manually updated. This * will also update the departArrive value if need be. */ - handleDateTimeChange = params => { - const {departArrive: prevDepartArrive} = this.props + handleDateTimeChange = (params) => { + const { departArrive: prevDepartArrive } = this.props // If previously set to leave now, change to depart at when time changes. if (prevDepartArrive === 'NOW') params.departArrive = 'DEPART' this.props.setQueryParam(params) @@ -158,25 +162,28 @@ class DateTimeOptions extends Component { /** * Select input string when time input is focused by user (for quick changes). */ - handleTimeFocus = evt => evt.target.select() + handleTimeFocus = (evt) => evt.target.select() - parseInputAsTime = (timeInput, date = moment().startOf('day').format('YYYY-MM-DD')) => { + parseInputAsTime = ( + timeInput, + date = moment().startOf('day').format('YYYY-MM-DD') + ) => { return moment(date + 'T' + timeInput, SUPPORTED_TIME_FORMATS) } dateTimeAsMoment = () => moment(`${this.props.date}T${this.props.time}`) - handleTimeChange = evt => { + handleTimeChange = (evt) => { if (this.state.timer) this._stopAutoRefresh() const timeInput = evt.target.value const parsedTime = this.parseInputAsTime(timeInput) this.handleDateTimeChange({ time: parsedTime.format(OTP_API_TIME_FORMAT) }) - this.setState({timeInput}) + this.setState({ timeInput }) } - render () { - const {departArrive, intl, onKeyDown} = this.props - const {timeInput} = this.state + render() { + const { departArrive, intl, onKeyDown } = this.props + const { timeInput } = this.state const dateTime = this.dateTimeAsMoment() return ( <> @@ -186,15 +193,21 @@ class DateTimeOptions extends Component { onKeyDown={onKeyDown} value={departArrive} > - {departureOptions.map(o => )} + {getDepartureOptions(intl).map(({ text, value }) => ( + + ))} {intl.formatTime(dateTime.unix())}} - placement='bottom' + overlay={ + {intl.formatTime(dateTime)} + } + placement="bottom" trigger={['focus', 'hover']} > diff --git a/lib/components/form/connect-location-field.js b/lib/components/form/connect-location-field.js index 0a91ae0e8..d898865f2 100644 --- a/lib/components/form/connect-location-field.js +++ b/lib/components/form/connect-location-field.js @@ -1,33 +1,68 @@ -import { injectIntl } from 'react-intl' +/* eslint-disable react/prop-types */ import { connect } from 'react-redux' +import { injectIntl } from 'react-intl' +import React from 'react' import * as apiActions from '../../actions/api' import * as locationActions from '../../actions/location' import { getActiveSearch, getShowUserSettings } from '../../util/state' +import { getUserLocations } from '../../util/user' +import Icon from '../util/icon' + +/** + * Custom icon component that renders based on the user location icon prop. + */ +const UserLocationIcon = ({ userLocation }) => { + const { icon = 'marker' } = userLocation + // Places from localStorage that are assigned the 'work' icon + // should be rendered as 'briefcase'. + const finalIcon = icon === 'work' ? 'briefcase' : icon + + return +} /** * This higher-order component connects the target (styled) LocationField to the * redux store. * @param StyledLocationField The input LocationField component to connect. - * @param options Optional object with the following optional props: + * @param options Optional object with the following optional props (see defaults in code): * - actions: a list of actions to include in mapDispatchToProps + * - excludeSavedLocations: whether to not render user-saved locations * - includeLocation: whether to derive the location prop from * the active query * @returns The connected component. */ -export default function connectLocationField (StyledLocationField, options = {}) { +export default function connectLocationField( + StyledLocationField, + options = {} +) { // By default, set actions to empty list and do not include location. - const {actions = {}, includeLocation = false, intlActions = {}} = options + const { + actions = {}, + excludeSavedLocations = false, + includeLocation = false + } = options const mapStateToProps = (state, ownProps) => { - const { config, currentQuery, location, transitIndex, user } = state.otp + const { config, currentQuery, location, transitIndex } = state.otp const { currentPosition, nearbyStops, sessionSearches } = location const activeSearch = getActiveSearch(state) const query = activeSearch ? activeSearch.query : currentQuery + // Display saved locations and recent places according to the configured persistence strategy, + // unless displaying user locations is disabled via prop (e.g. in the saved-place editor + // when the loggedInUser defines their saved locations). + let userSavedLocations = [] + let recentPlaces = [] + if (!excludeSavedLocations) { + const userLocations = getUserLocations(state) + userSavedLocations = userLocations.saved + recentPlaces = userLocations.recent + } + const geocoderConfig = config.geocoder if (currentPosition?.coords) { - const {latitude: lat, longitude: lon} = currentPosition.coords - geocoderConfig.focusPoint = {lat, lon} + const { latitude: lat, longitude: lon } = currentPosition.coords + geocoderConfig.focusPoint = { lat, lon } } const stateToProps = { @@ -38,7 +73,8 @@ export default function connectLocationField (StyledLocationField, options = {}) sessionSearches, showUserSettings: getShowUserSettings(state), stopsIndex: transitIndex.stops, - userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] + UserLocationIconComponent: UserLocationIcon, + userLocationsAndRecentPlaces: [...userSavedLocations, ...recentPlaces] } // Set the location prop only if includeLocation is specified, else leave unset. // Otherwise, the StyledLocationField component will use the fixed undefined/null value as location @@ -50,28 +86,15 @@ export default function connectLocationField (StyledLocationField, options = {}) return stateToProps } - const mapDispatchToProps = (dispatch, ownProps) => { - const allBaseActions = { - addLocationSearch: locationActions.addLocationSearch, - findNearbyStops: apiActions.findNearbyStops, - ...actions - } - - const allIntlActions = { - getCurrentPosition: locationActions.getCurrentPosition, - ...intlActions - } - - const dispatchActions = {} - - Object.entries(allBaseActions).forEach(([key, fn]) => { - dispatchActions[key] = (...args) => dispatch(fn(...args)) - }) - Object.entries(allIntlActions).forEach(([key, fn]) => { - dispatchActions[key] = (...args) => dispatch(fn(ownProps.intl, ...args)) - }) - return dispatchActions + const mapDispatchToProps = { + addLocationSearch: locationActions.addLocationSearch, + findNearbyStops: apiActions.findNearbyStops, + getCurrentPosition: locationActions.getCurrentPosition, + ...actions } - return connect(mapStateToProps, mapDispatchToProps)(injectIntl(StyledLocationField)) + return connect( + mapStateToProps, + mapDispatchToProps + )(injectIntl(StyledLocationField)) } diff --git a/lib/components/form/connected-links.js b/lib/components/form/connected-links.js index 1b598e5fb..964809a7e 100644 --- a/lib/components/form/connected-links.js +++ b/lib/components/form/connected-links.js @@ -21,10 +21,12 @@ const withQueryParams = (RoutingComponent) => // eslint-disable-next-line react/display-name ({ children, queryParams, to, ...props }) => - ( + to ? ( {children} + ) : ( + children ) // For connecting to the redux store diff --git a/lib/components/form/connected-location-field.js b/lib/components/form/connected-location-field.js index ac52fe42f..e7026b59b 100644 --- a/lib/components/form/connected-location-field.js +++ b/lib/components/form/connected-location-field.js @@ -1,4 +1,3 @@ -import LocationField from '@opentripplanner/location-field' import { DropdownContainer, FormGroup, @@ -7,6 +6,7 @@ import { InputGroupAddon, MenuItemA } from '@opentripplanner/location-field/lib/styled' +import LocationField from '@opentripplanner/location-field' import styled from 'styled-components' import * as mapActions from '../../actions/map' @@ -55,10 +55,8 @@ const StyledLocationField = styled(LocationField)` export default connectLocationField(StyledLocationField, { actions: { - clearLocation: mapActions.clearLocation - }, - includeLocation: true, - intlActions: { + clearLocation: mapActions.clearLocation, onLocationSelected: mapActions.onLocationSelected - } + }, + includeLocation: true }) diff --git a/lib/components/form/date-time-modal.js b/lib/components/form/date-time-modal.js index 7bc7396ca..c77b135f2 100644 --- a/lib/components/form/date-time-modal.js +++ b/lib/components/form/date-time-modal.js @@ -1,7 +1,9 @@ +// TODO: TypeScript with props. +/* eslint-disable react/prop-types */ +import { connect } from 'react-redux' import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React, { Component } from 'react' -import { connect } from 'react-redux' import { setQueryParam } from '../../actions/form' @@ -12,8 +14,9 @@ class DateTimeModal extends Component { setQueryParam: PropTypes.func } - render () { + render() { const { + config, date, dateFormatLegacy, departArrive, @@ -21,12 +24,16 @@ class DateTimeModal extends Component { time, timeFormatLegacy } = this.props + const { isTouchScreenOnDesktop } = config + const touchClassName = isTouchScreenOnDesktop + ? 'with-desktop-touchscreen' + : '' return ( -
-
+
+
{ +const mapStateToProps = (state) => { const { date, departArrive, time } = state.otp.currentQuery const config = state.otp.config return { config, date, + // This prop is for legacy browsers (see render method above). + dateFormatLegacy: coreUtils.time.getDateFormat(config), departArrive, time, - // These props below are for legacy browsers (see render method above). - // eslint-disable-next-line sort-keys - dateFormatLegacy: coreUtils.time.getDateFormat(config), + // This prop is for legacy browsers (see render method above). timeFormatLegacy: coreUtils.time.getTimeFormat(config) } } diff --git a/lib/components/form/form.css b/lib/components/form/form.css index 371098a50..203dee7c0 100644 --- a/lib/components/form/form.css +++ b/lib/components/form/form.css @@ -429,3 +429,20 @@ .otp .user-settings .disclaimer { font-size: x-small; } + +/* For Chrome/ium desktop browsers, if so configured, + set the date/time input controls to automatically show the date-time picker on focus. + This changes do not seem to affect Chrome/ium mobile browsers. */ +.otp .date-time-modal .date-time-selector.with-desktop-touchscreen input { + position: relative; +} +.otp .date-time-modal .date-time-selector.with-desktop-touchscreen input::-webkit-calendar-picker-indicator { + background-position-x: right; + margin-inline-end: 8px; + position: absolute; + right: 0; + width: 100%; +} +.otp .date-time-modal .date-time-selector.with-desktop-touchscreen input::-webkit-datetime-edit { + margin-inline-end: 8px; +} diff --git a/lib/components/form/mode-buttons.js b/lib/components/form/mode-buttons.js deleted file mode 100644 index a807f5a59..000000000 --- a/lib/components/form/mode-buttons.js +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useContext } from 'react' -import { OverlayTrigger, Tooltip } from 'react-bootstrap' -import { useIntl } from 'react-intl' -import styled from 'styled-components' - -import { ComponentContext } from '../../util/contexts' -import Icon from '../util/icon' - -import {buttonCss} from './batch-styled' - -export function getModeOptions (intl) { - // intl.formatMessage is used here instead of because the text is - // rendered inside , which renders outside of the context. - return [ - { - label: intl.formatMessage({id: 'common.modes.transit'}), - mode: 'TRANSIT' - }, - { - label: intl.formatMessage({id: 'common.modes.walking'}), - mode: 'WALK' - }, - { - label: intl.formatMessage({id: 'common.modes.drive'}), - mode: 'CAR' - }, - { - label: intl.formatMessage({id: 'common.modes.bicycle'}), - mode: 'BICYCLE' - }, - { - icon: 'mobile', - label: intl.formatMessage({id: 'common.modes.rent'}), - mode: 'RENT' // TODO: include HAIL? - } - ] -} - -const ModeButtons = ({ - className, - onClick, - selectedModes = [] -}) => { - const intl = useIntl() - return getModeOptions(intl).map((item, index) => ( - - )) -} - -const CheckMarkIcon = styled(Icon)` - position: absolute; - bottom: 2px; - right: 2px; - color: green; -` - -const ModeButton = ({className, item, onClick, selected}) => { - const {ModeIcon} = useContext(ComponentContext) - const {icon, label, mode} = item - return ( - {label}} - placement='bottom' - > - - - ) -} - -export const StyledModeButton = styled(ModeButton)` - ${buttonCss} - &.flex { - flex-grow: 1; - margin-right: 5px; - } - &.straight-corners { - border-radius: 0px; - } - border: 0px; - position: relative; -` - -export default ModeButtons diff --git a/lib/components/form/mode-buttons.tsx b/lib/components/form/mode-buttons.tsx new file mode 100644 index 000000000..13ec4cab4 --- /dev/null +++ b/lib/components/form/mode-buttons.tsx @@ -0,0 +1,123 @@ +import { IntlShape, useIntl } from 'react-intl' +import { OverlayTrigger, Tooltip } from 'react-bootstrap' +import React, { useContext } from 'react' +import styled from 'styled-components' + +import { ComponentContext } from '../../util/contexts' +import Icon from '../util/icon' + +import { buttonCss } from './batch-styled' + +type Mode = { + icon?: string + label: string + mode: string +} + +export function getModeOptions(intl: IntlShape): Mode[] { + // intl.formatMessage is used here instead of because the text is + // rendered inside , which renders outside of the context. + return [ + { + label: intl.formatMessage({ id: 'common.modes.transit' }), + mode: 'TRANSIT' + }, + { + label: intl.formatMessage({ id: 'common.modes.walking' }), + mode: 'WALK' + }, + { + label: intl.formatMessage({ id: 'common.modes.drive' }), + mode: 'CAR' + }, + { + label: intl.formatMessage({ id: 'common.modes.bicycle' }), + mode: 'BICYCLE' + }, + { + icon: 'mobile', + label: intl.formatMessage({ id: 'common.modes.rent' }), + mode: 'RENT' // TODO: include HAIL? + } + ] +} + +const CheckMarkIcon = styled(Icon)` + position: absolute; + bottom: 2px; + right: 2px; + color: green; +` + +const ModeButton = ({ + className, + item, + onClick, + selected +}: { + className: string + item: Mode + onClick: (mode: string) => void + selected: boolean +}): JSX.Element => { + // FIXME: type context + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { ModeIcon } = useContext(ComponentContext) + const { icon, label, mode } = item + return ( + {label}} + placement="bottom" + > + + + ) +} + +export const StyledModeButton = styled(ModeButton)` + ${buttonCss} + &.flex { + flex-grow: 1; + margin-right: 5px; + } + &.straight-corners { + border-radius: 0px; + } + border: 0px; + position: relative; +` + +const ModeButtons = ({ + className, + onClick, + selectedModes = [] +}: { + className: string + onClick: (mode: string) => void + selectedModes: string[] +}): JSX.Element => { + const intl = useIntl() + return ( + <> + {getModeOptions(intl).map((item, index) => ( + + ))} + + ) +} + +export default ModeButtons diff --git a/lib/components/form/styled.js b/lib/components/form/styled.js index 5cc5370ed..55089aa5d 100644 --- a/lib/components/form/styled.js +++ b/lib/components/form/styled.js @@ -1,6 +1,9 @@ -import styled, { css } from 'styled-components' -import { DateTimeSelector, SettingsSelectorPanel } from '@opentripplanner/trip-form' import * as TripFormClasses from '@opentripplanner/trip-form/lib/styled' +import { + DateTimeSelector, + SettingsSelectorPanel +} from '@opentripplanner/trip-form' +import styled, { css } from 'styled-components' const commonButtonCss = css` -webkit-user-select: none; @@ -34,7 +37,7 @@ export const commonInputCss = css` font-family: inherit; font-weight: inherit; padding: 6px 12px; - transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; ` export const modeButtonButtonCss = css` @@ -84,14 +87,15 @@ export const StyledSettingsSelectorPanel = styled(SettingsSelectorPanel)` &:focus { border-color: #66afe9; - box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), + 0 0 8px rgba(102, 175, 233, 0.6); outline: 0; } } > div:last-child::after { box-sizing: border-box; color: #000; - content: "▼"; + content: '▼'; font-size: 67%; pointer-events: none; position: absolute; @@ -137,7 +141,7 @@ export const StyledSettingsSelectorPanel = styled(SettingsSelectorPanel)` font-weight: 800; height: 46px; > svg { - margin: 0 0.20em; + margin: 0 0.2em; } } } @@ -202,3 +206,7 @@ export const StyledDateTimeSelector = styled(DateTimeSelector)` height: 35px; } ` + +export const UnpaddedList = styled.ul` + padding: 0; +` diff --git a/lib/components/form/user-settings.js b/lib/components/form/user-settings.js index 43bda57f5..78aeeb0b1 100644 --- a/lib/components/form/user-settings.js +++ b/lib/components/form/user-settings.js @@ -1,308 +1,422 @@ -import moment from 'moment' -import coreUtils from '@opentripplanner/core-utils' -import React, { Component } from 'react' +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable react/prop-types */ import { Button } from 'react-bootstrap' -import { FormattedMessage, injectIntl } from 'react-intl' import { connect } from 'react-redux' +import { FormattedMessage, injectIntl, useIntl } from 'react-intl' +import coreUtils from '@opentripplanner/core-utils' +import moment from 'moment' +import React, { Component, useCallback } from 'react' + +import * as apiActions from '../../actions/api' +import * as formActions from '../../actions/form' +import * as uiActions from '../../actions/ui' +import * as userActions from '../../actions/user' +import { getFormattedPlaces } from '../../util/i18n' +import { + getPlaceDetail, + getPlaceMainText, + isHome, + isOtpMiddleware, + isWork +} from '../../util/user' +import { LinkWithQuery } from '../form/connected-links' +import { StyledMainPanelPlace } from '../user/places/styled' +import PlaceShortcut from '../user/places/place-shortcut' -import { forgetSearch, toggleTracking } from '../../actions/api' -import { setQueryParam } from '../../actions/form' -import { forgetPlace, forgetStop, setLocation } from '../../actions/map' -import { setViewedStop } from '../../actions/ui' -import Icon from '../util/icon' +import { UnpaddedList } from './styled' -const { formatStoredPlaceName, getDetailText, matchLatLon } = coreUtils.map -const { summarizeQuery } = coreUtils.query +const { matchLatLon } = coreUtils.map +const { hasTransit, toSentenceCase } = coreUtils.itinerary +/** + * Version of summarizeQuery and helper function that supports i18n. + * FIXME: replace when the original does support i18n. + */ +function findLocationType( + intl, + location, + locations = [], + types = ['home', 'work', 'suggested'] +) { + const match = locations.find((l) => matchLatLon(l, location)) + return match && types.indexOf(match.type) !== -1 + ? getFormattedPlaces(match.type, intl) + : null +} +function summarizeQuery(query, intl, locations = []) { + const from = + findLocationType(intl, query.from, locations) || + query.from.name.split(',')[0] + const to = + findLocationType(intl, query.to, locations) || query.to.name.split(',')[0] + const mode = hasTransit(query.mode) + ? intl.formatMessage({ id: 'common.modes.transit' }) + : toSentenceCase(query.mode) + return intl.formatMessage( + { id: 'components.UserSettings.recentSearchSummary' }, + { from, mode, to } + ) +} -const BUTTON_WIDTH = 40 +/** + * Formats elapsed time from the specified timestamp to now, + * as in "5 hours ago" or the localized equivalent. + */ +function formatElapsedTime(timestamp, intl) { + // This text will be shown in a tooltip, therefore + // it is obtained using formaMessage rather than . + const fromNowDur = moment.duration(moment().diff(moment(timestamp))) + return intl + .formatMessage( + { id: 'common.time.fromNowUpdate' }, + { + days: fromNowDur.days(), + hours: fromNowDur.hours(), + minutes: fromNowDur.minutes() + } + ) + .trim() +} class UserSettings extends Component { _disableTracking = () => { - const { intl, toggleTracking, user } = this.props - if (!user.trackRecent) return - const hasRecents = user.recentPlaces.length > 0 || user.recentSearches.length > 0 + const { intl, localUser, toggleTracking } = this.props + const { recentPlaces, recentSearches, storeTripHistory } = localUser + if (!storeTripHistory) return + const hasRecents = recentPlaces?.length > 0 || recentSearches?.length > 0 + // If user has recents and does not confirm deletion, return without doing // anything. - if (hasRecents && !window.confirm(intl.formatMessage({id: 'components.UserSettings.confirmDeletion'}))) { + if ( + hasRecents && + !window.confirm( + intl.formatMessage({ id: 'components.UserSettings.confirmDeletion' }) + ) + ) { return } // Disable tracking if we reach this statement. toggleTracking(false) } - _enableTracking = () => !this.props.user.trackRecent && this.props.toggleTracking(true) + _enableTracking = () => + !this.props.localUser.storeTripHistory && this.props.toggleTracking(true) + + _getLocalStorageOnlyContent = () => { + const { + deleteLocalUserRecentPlace, + forgetSearch, + forgetStop, + intl, + localUser, + setQueryParam, + setViewedStop + } = this.props + + const { favoriteStops, recentPlaces, recentSearches, storeTripHistory } = + localUser + return ( + <> + {/* Favorite stops are shown regardless of tracking. */} + { + return intl.formatMessage( + { id: 'components.UserSettings.stopId' }, + { stopId: location.id } + ) + }} + getMainText={(location) => location.address} + header={ + + } + onDelete={forgetStop} + onView={setViewedStop} + places={favoriteStops} + textIfEmpty={ + + } + /> + + {storeTripHistory && ( + { + return formatElapsedTime(location.timestamp, intl) + }} + getMainText={(location) => location.address} + header={ + + } + onDelete={deleteLocalUserRecentPlace} + places={recentPlaces} + /> + )} + {storeTripHistory && ( + + )} +
+
+
+ +
+ + {' '} + + + +
+
+
+
+ +
+
+ + ) + } _getLocations = (user) => { - const locations = [...user.locations] - if (!locations.find(l => l.type === 'work')) { + const locations = [...user.savedLocations] + if (!locations.find(isWork)) { locations.push({ - blank: true, + address: '', icon: 'briefcase', id: 'work', - name: 'click to add', type: 'work' }) } - if (!locations.find(l => l.type === 'home')) { + if (!locations.find(isHome)) { locations.push({ - blank: true, + address: '', icon: 'home', id: 'home', - name: 'click to add', type: 'home' }) } return locations } - render () { - const { user } = this.props - const { favoriteStops, recentPlaces, recentSearches, trackRecent } = user + _deleteUserPlace = (place) => { + const { deleteUserPlace, intl } = this.props + deleteUserPlace(place, intl) + } + + render() { + const { + className = '', + forgetSearch, + intl, + isUsingOtpMiddleware, + localUser, + loggedInUser, + loggedInUserTripRequests, + setQueryParam, + style + } = this.props + const userNotLoggedIn = isUsingOtpMiddleware && !loggedInUser + if (userNotLoggedIn) return null + // Clone locations in order to prevent blank locations from seeping into the // app state/store. - const locations = this._getLocations(user) + const locations = this._getLocations( + isUsingOtpMiddleware ? loggedInUser : localUser + ) const order = ['home', 'work', 'suggested', 'stop', 'recent'] - const sortedLocations = locations - .sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type)) + const sortedLocations = isUsingOtpMiddleware + ? locations + : locations.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type)) return ( -
-
    - {sortedLocations.map(location => { - return - })} -
-
-
-
    - {favoriteStops.length > 0 - ? favoriteStops.map(location => { - return - }) - : +
    + {/* Sorted locations are shown regardless of tracking. */} + getPlaceDetail(location, intl)} + getMainText={(location) => getPlaceMainText(location, intl)} + header={ + isUsingOtpMiddleware && ( + ( + + {linkText} + + ) + }} + /> + ) } -
- {trackRecent && recentPlaces.length > 0 && -
-
-
-
    - {recentPlaces.map(location => { - return - })} -
-
- } - {trackRecent && recentSearches.length > 0 && -
-
-
-
    - {recentSearches - .sort((a, b) => b.timestamp - a.timestamp) - .map(search => { - return - }) - } -
-
- } -
-
-
- - - -
-
-
-
- -
-
+ onDelete={this._deleteUserPlace} + places={sortedLocations} + separator={false} + /> + {isUsingOtpMiddleware ? ( + + ) : ( + this._getLocalStorageOnlyContent() + )}
) } } -class Place extends Component { - _onSelect = () => { - const { intl, location, query, setLocation } = this.props - if (location.blank) { - window.alert(intl.formatMessage({id: 'components.Place.enterAlert'}, {type: location.type})) - } else { - // If 'to' not set and 'from' does not match location, set as 'to'. - if ( - !query.to && ( - !query.from || !matchLatLon(location, query.from) - ) - ) { - setLocation({ location, locationType: 'to' }) - } else if ( - // Vice versa for setting as 'from'. - !query.from && - !matchLatLon(location, query.to) - ) { - setLocation({ location, locationType: 'from' }) - } - } - } - - _onView = () => { - const { location, setViewedStop } = this.props - setViewedStop({ stopId: location.id }) - } - - _onForget = () => { - const { forgetPlace, forgetStop, location } = this.props - if (location.type === 'stop') forgetStop(location.id) - else forgetPlace(location.id) - } - - _isViewable = () => this.props.location.type === 'stop' - - _isForgettable = () => - ['stop', 'home', 'work', 'recent'].indexOf(this.props.location.type) !== -1 - - render () { - const { intl } = this.props - const { location } = this.props - const { blank, icon } = location - const showView = this._isViewable() - const showForget = this._isForgettable() && !blank - // Determine how much to offset width of main button (based on visibility of - // other buttons sharing the same line). - let offset = 0 - if (showView) offset += BUTTON_WIDTH - if (showForget) offset += BUTTON_WIDTH - return ( -
  • - - {showView && - - } - {showForget && - - } -
  • +/** + * Displays a list of places with a header. + */ +const Places = ({ + getDetailText, + getMainText, + header, + onDelete, + onView, + places, + separator = true, + textIfEmpty +}) => { + const shouldRender = textIfEmpty || (places && places.length > 0) + return ( + shouldRender && ( + <> + {separator &&
    } + {header &&
    {header}
    } + + {places.length > 0 + ? places.map((location, index) => { + // using the `return` syntax instead of `=>` to please both + // prettier and react/jsx rules, otherwise they conflict. + return ( + + ) + }) + : textIfEmpty && {textIfEmpty}} + + ) - } + ) } -class RecentSearch extends Component { - _onSelect = () => { - const { search, setQueryParam } = this.props +/** + * Wrapper for recent trip requests. + */ +const TripRequest = ({ forgetSearch, setQueryParam, tripRequest, user }) => { + const { canDelete = true, id, query, timestamp } = tripRequest + + const _onSelect = useCallback( // Update query params and initiate search. - setQueryParam(search.query, search.id) - } + () => setQueryParam(query, id), + [setQueryParam, query, id] + ) - _onForget = () => this.props.forgetSearch(this.props.search.id) + const _onForget = useCallback( + () => forgetSearch(tripRequest), + [forgetSearch, tripRequest] + ) - render () { - const { intl, search, user } = this.props - const { query, timestamp } = search - const name = summarizeQuery(query, user.locations) - const fromNowDur = moment.duration(moment().diff(moment(timestamp))) + const intl = useIntl() + const mainText = summarizeQuery(query, intl, user.savedLocations).trim() + return ( + + ) +} - return ( -
  • - - -
  • - ) - } -} + ))} + +
    + ) // connect to redux store -const mapStateToProps = (state, ownProps) => { +const mapStateToProps = (state) => { + const { config, currentQuery } = state.otp + const { localUser, loggedInUser, loggedInUserTripRequests } = state.user return { - config: state.otp.config, - currentPosition: state.otp.location.currentPosition, - nearbyStops: state.otp.location.nearbyStops, - query: state.otp.currentQuery, - sessionSearches: state.otp.location.sessionSearches, - stopsIndex: state.otp.transitIndex.stops, - user: state.otp.user + isUsingOtpMiddleware: isOtpMiddleware(config.persistence), + localUser, + loggedInUser, + loggedInUserTripRequests, + query: currentQuery } } const mapDispatchToProps = { - forgetPlace, - forgetSearch, - forgetStop, - setLocation, - setQueryParam, - setViewedStop, - toggleTracking + deleteLocalUserRecentPlace: userActions.deleteLocalUserRecentPlace, + deleteUserPlace: userActions.deleteUserPlace, + forgetSearch: apiActions.forgetSearch, + forgetStop: userActions.forgetStop, + setQueryParam: formActions.setQueryParam, + setViewedStop: uiActions.setViewedStop, + toggleTracking: apiActions.toggleTracking } -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(UserSettings)) +export default connect( + mapStateToProps, + mapDispatchToProps +)(injectIntl(UserSettings)) diff --git a/lib/components/form/user-trip-settings.js b/lib/components/form/user-trip-settings.js index a9cb81655..55f09ff0a 100644 --- a/lib/components/form/user-trip-settings.js +++ b/lib/components/form/user-trip-settings.js @@ -1,8 +1,9 @@ -import coreUtils from '@opentripplanner/core-utils' -import React, { Component } from 'react' +/* eslint-disable react/prop-types */ import { Button } from 'react-bootstrap' -import { FormattedMessage } from 'react-intl' import { connect } from 'react-redux' +import { FormattedMessage } from 'react-intl' +import coreUtils from '@opentripplanner/core-utils' +import React, { Component } from 'react' import { clearDefaultSettings, @@ -25,13 +26,8 @@ class UserTripSettings extends Component { else this.props.storeDefaultSettings(options) } - render () { - const { - config, - defaults, - query, - resetForm - } = this.props + render() { + const { config, defaults, query, resetForm } = this.props // Do not permit remembering trip options if they do not differ from the // defaults and nothing has been stored @@ -39,26 +35,38 @@ class UserTripSettings extends Component { const rememberIsDisabled = queryIsDefault && !defaults return ( -
    +
    + > + {defaults ? ( + + {' '} + + + ) : ( + + {' '} + + + )} +
    @@ -68,9 +76,9 @@ class UserTripSettings extends Component { // connect to redux store -const mapStateToProps = (state, ownProps) => { - const { config, currentQuery, user } = state.otp - const { defaults } = user +const mapStateToProps = (state) => { + const { config, currentQuery } = state.otp + const { defaults } = state.user.localUser return { config, diff --git a/lib/components/map/bounds-updating-overlay.js b/lib/components/map/bounds-updating-overlay.js index ae97c29c6..93c32d0c3 100644 --- a/lib/components/map/bounds-updating-overlay.js +++ b/lib/components/map/bounds-updating-overlay.js @@ -86,6 +86,9 @@ class BoundsUpdatingOverlay extends MapLayer { const { map } = newProps.leaflet if (!map) return + const itineraryShown = newProps.mainPanelContent === null + if (!itineraryShown) return + // Fit map to to entire itinerary if active itinerary bounds changed const newFrom = newProps.query && newProps.query.from const newItinBounds = @@ -106,8 +109,9 @@ class BoundsUpdatingOverlay extends MapLayer { // Also refit map if itineraryView prop has changed. const itineraryViewChanged = oldProps.itineraryView !== newProps.itineraryView + const isMobile = coreUtils.ui.isMobile() - if (oldMapConfig !== newMapConfig) { + if (oldMapConfig !== newMapConfig && !isMobile) { setTimeout(() => { map.invalidateSize(true) map.setZoom(newMapConfig.initZoom || 13) @@ -195,6 +199,7 @@ const mapStateToProps = (state) => { activeStep: activeSearch && activeSearch.activeStep, itinerary: getActiveItinerary(state), itineraryView: urlParams.ui_itineraryView, + mainPanelContent: state.otp.ui.mainPanelContent, mapConfig: state.otp.config.map, popupLocation: state.otp.ui.mapPopupLocation, query: state.otp.currentQuery diff --git a/lib/components/map/connected-endpoints-overlay.js b/lib/components/map/connected-endpoints-overlay.js index 4d01c68ba..7af1c562f 100644 --- a/lib/components/map/connected-endpoints-overlay.js +++ b/lib/components/map/connected-endpoints-overlay.js @@ -1,17 +1,28 @@ -import EndpointsOverlay from '@opentripplanner/endpoints-overlay' +/* eslint-disable react/prop-types */ import { connect } from 'react-redux' +import { useIntl } from 'react-intl' +import EndpointsOverlay from '@opentripplanner/endpoints-overlay' +import React, { useCallback } from 'react' -import { - clearLocation, - forgetPlace, - rememberPlace, - setLocation -} from '../../actions/map' +import { clearLocation, forgetPlace, setLocation } from '../../actions/map' import { getActiveSearch, getShowUserSettings } from '../../util/state' +import { getUserLocations } from '../../util/user' +import { rememberPlace } from '../../actions/user' + +const ConnectedEndpointsOverlay = (props) => { + const intl = useIntl() + const _forgetPlace = useCallback( + (place) => { + props.forgetPlace(place, intl) + }, + [props, intl] + ) + return +} // connect to the redux store -const mapStateToProps = (state, ownProps) => { +const mapStateToProps = (state) => { const { viewedRoute } = state.otp.ui // If the route viewer is active, do not show itinerary on map. // mainPanelContent is null whenever the trip planner is active. @@ -30,11 +41,12 @@ const mapStateToProps = (state, ownProps) => { const { from, to } = query // Intermediate places doesn't trigger a re-plan, so for now default to // current query. FIXME: Determine with TriMet if this is desired behavior. - const places = state.otp.currentQuery.intermediatePlaces.filter(p => p) + const places = state.otp.currentQuery.intermediatePlaces.filter((p) => p) + return { fromLocation: from, intermediatePlaces: places, - locations: state.otp.user.locations, + locations: getUserLocations(state).saved, showUserSettings, toLocation: to, visible: true @@ -48,4 +60,7 @@ const mapDispatchToProps = { setLocation } -export default connect(mapStateToProps, mapDispatchToProps)(EndpointsOverlay) +export default connect( + mapStateToProps, + mapDispatchToProps +)(ConnectedEndpointsOverlay) diff --git a/lib/components/map/connected-route-viewer-overlay.js b/lib/components/map/connected-route-viewer-overlay.js index adb99c8a4..eba2527b5 100644 --- a/lib/components/map/connected-route-viewer-overlay.js +++ b/lib/components/map/connected-route-viewer-overlay.js @@ -1,5 +1,7 @@ -import RouteViewerOverlay from '@opentripplanner/route-viewer-overlay' import { connect } from 'react-redux' +import RouteViewerOverlay from '@opentripplanner/route-viewer-overlay' + +import { MainPanelContent } from '../../actions/ui' // connect to the redux store @@ -14,10 +16,18 @@ const mapStateToProps = (state, ownProps) => { // If a pattern is selected, hide all other patterns if (viewedRoute?.patternId && routeData?.patterns) { - filteredPatterns = {[viewedRoute.patternId]: routeData.patterns[viewedRoute.patternId]} + filteredPatterns = { + [viewedRoute.patternId]: routeData.patterns[viewedRoute.patternId] + } } return { + allowMapCentering: + // TODO: allow panning of the map after initial click, but before pattern + // viewer open + state.otp.ui?.mainPanelContent === MainPanelContent.ROUTE_VIEWER, + clipToPatternStops: + state.otp.config?.routeViewer?.hideRouteShapesWithinFlexZones, routeData: { ...routeData, patterns: filteredPatterns } } } diff --git a/lib/components/map/connected-stops-overlay.js b/lib/components/map/connected-stops-overlay.js index 643f08c7c..38d217af3 100644 --- a/lib/components/map/connected-stops-overlay.js +++ b/lib/components/map/connected-stops-overlay.js @@ -1,5 +1,5 @@ -import StopsOverlay from '@opentripplanner/stops-overlay' import { connect } from 'react-redux' +import StopsOverlay from '@opentripplanner/stops-overlay' import { findStopsWithinBBox } from '../../actions/api' @@ -9,14 +9,36 @@ import StopMarker from './connected-stop-marker' const mapStateToProps = (state, ownProps) => { const { viewedRoute } = state.otp.ui + const { routes } = state.otp.transitIndex let { stops } = state.otp.overlay.transit let minZoom = 15 - // If a pattern is being shown, show only the pattern's stops and show them large - if (viewedRoute?.patternId && state.otp.transitIndex.routes) { - stops = state.otp.transitIndex.routes[viewedRoute.routeId]?.patterns?.[viewedRoute.patternId].stops - minZoom = 2 + // There are a number of cases when stops within route objects should be shown + // although there are no stops that would otherwise be shown. In this case we need + // to override the stops index + if (routes) { + // All cases of stops being shown even when stops are otherwise hidden + // only apply when a route is being actively viewed + const route = routes?.[viewedRoute?.routeId] + if (route?.patterns) { + // If the pattern viewer is active, stops along that pattern should be shown + let viewedPattern = viewedRoute?.patternId + // If a flex route is being shown but the pattern viewer is not active, then the + // stops of the first pattern of the route should be shown + // This will ensure that the flex zone stops are shown. + + // Preferably, the flex stops would be rendered in a separate layer. + // However, without changes to GraphQL, getting this data is very expensive + if (!viewedPattern && route.v2) { + viewedPattern = Object.keys(route?.patterns)?.[0] + } + + // Override the stop index so that only relevant stops are shown + stops = route.patterns?.[viewedPattern]?.stops + // Override the minimum zoom so that the stops appear even if zoomed out + minZoom = 2 + } } return { diff --git a/lib/components/map/enhanced-stop-marker.js b/lib/components/map/enhanced-stop-marker.js index 1e53ab7db..f321561a7 100644 --- a/lib/components/map/enhanced-stop-marker.js +++ b/lib/components/map/enhanced-stop-marker.js @@ -186,7 +186,7 @@ const mapStateToProps = (state, ownProps) => { }) return { - activeStopId: state.otp.ui.viewedStop.stopId, + activeStopId: state.otp.ui.viewedStop?.stopId, highlight: highlightedStop === ownProps.entity.id, languageConfig: state.otp.config.language, modeColors, diff --git a/lib/components/map/map.css b/lib/components/map/map.css index 77652d126..2cb3349b7 100644 --- a/lib/components/map/map.css +++ b/lib/components/map/map.css @@ -15,7 +15,7 @@ } .otp .link-button:focus { - outline:0; + outline: 0; } /* leg diagram */ @@ -29,8 +29,8 @@ z-index: 1000; background-color: white; background-clip: padding-box; - border: 2px solid rgba(127, 127, 127, .5); - border-Radius: 4px; + border: 2px solid rgba(127, 127, 127, 0.5); + border-radius: 4px; cursor: crosshair; } @@ -64,6 +64,22 @@ background: none; } +.otp .leg-diagram .mapillary-close-button { + background: rgba(255, 255, 255, 0.85); + border-radius: 0 0 1em; + display: block; + left: 0; + max-width: 40px; + padding: 0.5em; + position: absolute; + top: 0; + z-index: 10000; +} +.otp .leg-diagram .mapillary-close-button:hover { + background: rgba(200, 200, 200, 0.85); + color: #333; +} + /*** Car Rental Map Icons ***/ .otp .car-rental-icon { diff --git a/lib/components/map/map.js b/lib/components/map/map.js index 12e01609e..4b8ca41e2 100644 --- a/lib/components/map/map.js +++ b/lib/components/map/map.js @@ -1,43 +1,56 @@ -import React, { Component } from 'react' +/* eslint-disable react/prop-types */ +// TODO: Typescript (config object) +import { Button, ButtonGroup } from 'react-bootstrap' import { connect } from 'react-redux' -import { ButtonGroup, Button } from 'react-bootstrap' +import React, { Component } from 'react' + +import { setMapillaryId } from '../../actions/map' import DefaultMap from './default-map' import LegDiagram from './leg-diagram' +import MapillaryFrame from './mapillary-frame' import StylizedMap from './stylized-map' class Map extends Component { - constructor () { + constructor() { super() this.state = { activeViewIndex: 0 } } - getComponentForView (view) { + getComponentForView(view) { // TODO: allow a 'CUSTOM' type switch (view.type) { - case 'DEFAULT': return - case 'STYLIZED': return + case 'DEFAULT': + return + case 'STYLIZED': + return } } - render () { - const { diagramLeg, mapConfig } = this.props + render() { + const { activeMapillaryImage, diagramLeg, mapConfig, setMapillaryId } = + this.props const showDiagram = diagramLeg + const showMapillary = activeMapillaryImage // Use the views defined in the config; if none defined, just show the default map const views = mapConfig.views || [{ type: 'DEFAULT' }] return ( -
    +
    {/* The map views -- only one is visible at a time */} {views.map((view, i) => { return ( -
    {this.getComponentForView(view)}
    @@ -46,15 +59,26 @@ class Map extends Component { {/* The toggle buttons -- only show if multiple views */} {views.length > 1 && ( -
    +
    {views.map((view, i) => { return (
    ) } @@ -76,9 +106,14 @@ class Map extends Component { const mapStateToProps = (state, ownProps) => { return { + activeMapillaryImage: state.otp.ui.mapillaryId, diagramLeg: state.otp.ui.diagramLeg, mapConfig: state.otp.config.map } } -export default connect(mapStateToProps)(Map) +const mapDispatchToProps = { + setMapillaryId +} + +export default connect(mapStateToProps, mapDispatchToProps)(Map) diff --git a/lib/components/map/mapillary-frame.tsx b/lib/components/map/mapillary-frame.tsx new file mode 100644 index 000000000..795f8d48a --- /dev/null +++ b/lib/components/map/mapillary-frame.tsx @@ -0,0 +1,55 @@ +import { Button } from 'react-bootstrap' +import React, { useEffect, useState } from 'react' + +// eslint-disable-next-line sort-imports-es6-autofix/sort-imports-es6 +import Icon from '../util/icon' + +const MapillaryFrame = ({ + id, + onClose +}: { + id: string + onClose?: () => void +}): React.ReactElement => { + const [fakeLoad, setFakeLoad] = useState(false) + useEffect(() => { + // If the ID changed, show a "fake" loading screen to indicate to the user + // something is happening + setFakeLoad(true) + setTimeout(() => setFakeLoad(false), 750) + }, [id]) + + return ( +
    +
    + +
    +