diff --git a/.babelrc b/.babelrc
index eb9ca3fc8..978a11243 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,5 +1,9 @@
{
- "plugins": ["babel-plugin-add-module-exports", "babel-plugin-lodash"],
+ "plugins": [
+ "babel-plugin-add-module-exports",
+ "@babel/plugin-proposal-class-properties",
+ "babel-plugin-lodash"
+ ],
"presets": [
"@babel/preset-env",
"@babel/preset-typescript",
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..b1e4ad0ca
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,14 @@
+
+**Description:**
+
+
+**PR Checklist:**
+- [ ] Does the code follow accessibility standards (WCAG 2.1 AA Compliant)?
+- [ ] Are all languages supported (Internationalization/Localization)?
+- [ ] Are appropriate Typescript types implemented?
+
+
+
+
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 000000000..cc58e2285
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,74 @@
+# This file was generated by GitHub
+
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ "dev", master ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ "dev" ]
+ schedule:
+ - cron: '23 11 * * 4'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'javascript' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+ # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+
+ # âšī¸ Command-line programs to run using the OS shell.
+ # đ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+
+ # If the Autobuild fails above, remove it and uncomment the following three lines.
+ # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
+
+ # - run: |
+ # echo "Run, Build Application using script"
+ # ./location_of_script_within_repo/buildscript.sh
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml
index f40c59996..ac9c8c91e 100644
--- a/.github/workflows/codespell.yml
+++ b/.github/workflows/codespell.yml
@@ -12,7 +12,7 @@ jobs:
- uses: codespell-project/actions-codespell@master
with:
check_filenames: true
- # skip git, yarn, and i18n non-english resources.
+ # skip git, yarn, pixel test script, and all i18n resources.
# Also, the a11y test file has a false positive and the ignore list does not work
# see https://github.com/opentripplanner/otp-react-redux/pull/436/checks?check_run_id=3369380014
- skip: ./.git,yarn.lock,./a11y/a11y.test.js,./i18n/fr*
+ skip: ./.git,yarn.lock,./a11y/a11y.test.js,./a11y/mocks,./percy/percy.test.js,./i18n
diff --git a/.github/workflows/percy.yml b/.github/workflows/percy.yml
new file mode 100644
index 000000000..5dc6abc37
--- /dev/null
+++ b/.github/workflows/percy.yml
@@ -0,0 +1,84 @@
+name: Percy
+
+on:
+ push:
+ branches:
+ - master
+ - dev
+ pull_request:
+
+jobs:
+ run-pixel-tests-with-otp1-real-server:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ # This allows us to work with the repository during the lint step
+ fetch-depth: 2
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v1
+ with:
+ node-version: 16.x
+ - name: Install npm packages using cache
+ uses: bahmutov/npm-install@v1
+ - name: Download OTP1 config file
+ run: curl $PERCY_OTP1_CONFIG_URL --output /tmp/otp1config.yml
+ env:
+ PERCY_OTP1_CONFIG_URL: ${{ secrets.PERCY_OTP1_CONFIG_URL_METRO }}
+ - name: Take Percy Snapshots
+ # Move everything from latest commit back to staged
+ run: npx percy exec -- npx jest percy/percy.test.js --force-exit
+ env:
+ PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
+ PERCY_OTP_CONFIG_OVERRIDE: /tmp/otp1config.yml
+ run-pixel-tests-with-otp2-real-server:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ # This allows us to work with the repository during the lint step
+ fetch-depth: 2
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v1
+ with:
+ node-version: 16.x
+ - name: Install npm packages using cache
+ uses: bahmutov/npm-install@v1
+ - name: Download OTP2 config file
+ run: curl $PERCY_OTP2_CONFIG_URL --output /tmp/otp2config.yml
+ env:
+ PERCY_OTP2_CONFIG_URL: ${{ secrets.PERCY_OTP2_CONFIG_URL_METRO }}
+ - name: Take Percy Snapshots
+ # Move everything from latest commit back to staged
+ run: npx percy exec -- npx jest percy/percy.test.js --force-exit
+ env:
+ PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_OTP2 }}
+ PERCY_OTP_CONFIG_OVERRIDE: /tmp/otp2config.yml
+ OTP_RR_PERCY_MOBILE: true
+ run-pixel-tests-with-otp2-real-server-call-taker:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ # This allows us to work with the repository during the lint step
+ fetch-depth: 2
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v1
+ with:
+ node-version: 16.x
+ - name: Install npm packages using cache
+ uses: bahmutov/npm-install@v1
+ - name: Download OTP2 config file
+ run: curl $PERCY_OTP2_CONFIG_URL --output /tmp/otp2config.yml
+ env:
+ PERCY_OTP2_CONFIG_URL: ${{ secrets.PERCY_OTP2_CONFIG_URL_METRO }}
+ - name: Take Percy Snapshots
+ # Move everything from latest commit back to staged
+ run: npx percy exec -- npx jest percy/percy.test.js --force-exit
+ env:
+ PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_CALL_TAKER_OTP2 }}
+ PERCY_OTP_CONFIG_OVERRIDE: /tmp/otp2config.yml
+ OTP_RR_PERCY_CALL_TAKER: true
diff --git a/.gitignore b/.gitignore
index 7c2fddd09..083a54cbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ dist
*config.yml
!example-config.yml
!test-config.yml
+!har-mock-config.yml
diff --git a/README.md b/README.md
index 59f4e7857..1ab2e1878 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,89 @@ The same environment variables which affect the behavior of `yarn start` also af
env JS_CONFIG=my-custom-js.js CUSTOM_CSS=my-custom-css.css yarn build
```
+## Internationalization
+
+OTP-react-redux uses `react-intl` from the [`formatjs`](https://github.com/formatjs/formatjs) library for internationalization.
+Both `react-intl` and `formatjs` take advantage of native internationalization features provided by web browsers.
+
+### `i18n` Folder
+
+Language-specific content is located in YML files under the `i18n` folder
+(e.g. `en-US.yml` for American English, `fr.yml` for generic French, etc.).
+
+In each of these files:
+ - 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:
+ ```yaml
+ common
+ modes
+ subway: Metro
+ ```
+ then use the snippet below with the corresponding message id:
+ ```jsx
+ // renders "Metro".
+ ```
+
+In these YML files, 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`, `common`, `components`, and `config`
+ categories. Additional categories may be added as needed.
+ - Each sub-category under `components` denotes a React 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.
+
+Note: Do not put comments in the YML files! They will be removed by `yaml-sort`.
+Instead, comments for other developers should be placed in the corresponding js/jsx/ts/tsx file.
+Comments for translators should be entered into Weblate (see [Contributing Translations](#contributing-translations))
+
+### Internationalizable content in the configuration file
+
+Most textual content from the `i18n` folder can also be customized on a per-configuration basis
+using the `language` section of `config.yml`, whether for all languages at once,
+or for each supported individual language.
+
+### Using internationalizable content in the code
+
+Use message id **literals** (no variables or other dynamic content) with either
+```jsx
+
+```
+or
+```js
+intl.formatMessage({ id: ... })
+```
+
+The reason for passing **literals** to `FormattedMessage` and `intl.formatMessage` is that we have a checker script `yarn check:i18n` that is based on the `formatJS` CLI and that detects unused messages in the code and exports translation tables.
+Passing variables or dynamic content will cause the `formatJS` CLI and the checker to ignore the corresponding messages and
+incorrectly claim that a string is unused or missing from a translation file.
+
+One exception to this rule concerns configuration settings where message ids can be constructed dynamically.
+
+### Contributing translations
+
+OTP-react-redux now uses [Hosted Weblate](https://www.weblate.org) to manage translations!
+
+
+
+
+Translations from the community are welcome and very much appreciated,
+please see instructions at https://hosted.weblate.org/projects/otp-react-redux/.
+Community input from Weblate will appear as pull requests with changes to files in the `i18n` folder for our review.
+(Contributions may be edited or rejected to remain in line with long-term project goals.)
+
+If changes to a specific language file is needed but not enabled in Weblate, please open an issue or a pull request with the changes needed.
+
## Library Documentation
More coming soon...
diff --git a/__tests__/actions/__snapshots__/api.js.snap b/__tests__/actions/__snapshots__/api.js.snap
index af787deaa..c8b742c84 100644
--- a/__tests__/actions/__snapshots__/api.js.snap
+++ b/__tests__/actions/__snapshots__/api.js.snap
@@ -56,7 +56,7 @@ Array [
"error": [Error: Received error from server],
"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",
+ "url": "http://mock-host.com:80/api/plan?fromPlace=%2812%2C34%29%3A%3A12%2C34&toPlace=%2834%2C12%29%3A%3A34%2C12&mode=WALK%2CTRANSIT&ignoreRealtimeUpdates=false&batchId=abcd1236",
},
"type": "ROUTING_ERROR",
},
@@ -99,4 +99,4 @@ Array [
]
`;
-exports[`actions > api routingQuery should make a query to OTP: OTP Query Path 1`] = `"/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"`;
+exports[`actions > api routingQuery should make a query to OTP: OTP Query Path 1`] = `"/api/plan?fromPlace=%2812%2C34%29%3A%3A12%2C34&toPlace=%2834%2C12%29%3A%3A34%2C12&mode=WALK%2CTRANSIT&ignoreRealtimeUpdates=false&batchId=abcd1234"`;
diff --git a/__tests__/actions/api.js b/__tests__/actions/api.js
index 8b5dee1c2..e69da0c84 100644
--- a/__tests__/actions/api.js
+++ b/__tests__/actions/api.js
@@ -2,6 +2,7 @@
import nock from 'nock'
+import '../test-utils/mock-window-url'
import * as api from '../../lib/actions/api'
// Use mocked randId function and pass in searchId for routingQuery calls so that
@@ -9,6 +10,19 @@ import * as api from '../../lib/actions/api'
let idCounter = 1234
const randId = () => `abcd${idCounter++}`
+/**
+ * Sets the requestId values as needed to deterministic IDs.
+ */
+function setMockRequestIds(calls) {
+ calls.forEach((call) => {
+ call.forEach((action) => {
+ if (action.payload && action.payload.requestId) {
+ action.payload.requestId = randId()
+ }
+ })
+ })
+}
+
describe('actions > api', () => {
describe('routingQuery', () => {
const defaultState = {
@@ -64,16 +78,3 @@ describe('actions > api', () => {
})
})
})
-
-/**
- * Sets the requestId values as needed to deterministic IDs.
- */
-function setMockRequestIds (calls) {
- calls.forEach(call => {
- call.forEach(action => {
- if (action.payload && action.payload.requestId) {
- action.payload.requestId = randId()
- }
- })
- })
-}
diff --git a/__tests__/components/__snapshots__/date-time-options.js.snap b/__tests__/components/__snapshots__/date-time-options.js.snap
new file mode 100644
index 000000000..620284217
--- /dev/null
+++ b/__tests__/components/__snapshots__/date-time-options.js.snap
@@ -0,0 +1,820 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components > form > call-taker > date time options should correctly handle "12a" 1`] = `
+
+
+
+
+
+
+ 12:00 AM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
+
+exports[`components > form > call-taker > date time options should correctly handle "12p" 1`] = `
+
+
+
+
+
+
+ 12:00 PM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
+
+exports[`components > form > call-taker > date time options should correctly handle "133" 1`] = `
+
+
+
+
+
+
+ 1:03 PM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
+
+exports[`components > form > call-taker > date time options should correctly handle "133p" 1`] = `
+
+
+
+
+
+
+ 1:33 PM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
+
+exports[`components > form > call-taker > date time options should correctly handle "135p" 1`] = `
+
+
+
+
+
+
+ 1:35 PM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
+
+exports[`components > form > call-taker > date time options should correctly handle "1335" 1`] = `
+
+
+
+
+
+
+ 1:35 PM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
+
+exports[`components > form > call-taker > date time options should render 1`] = `
+
+
+
+
+
+
+ 12:34 PM
+
+ }
+ placement="bottom"
+ trigger={
+ Array [
+ "focus",
+ "hover",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+`;
diff --git a/__tests__/components/date-time-options.js b/__tests__/components/date-time-options.js
new file mode 100644
index 000000000..d2737d38f
--- /dev/null
+++ b/__tests__/components/date-time-options.js
@@ -0,0 +1,90 @@
+import '../test-utils/mock-window-url'
+import {
+ getMockInitialState,
+ mockWithProvider
+} from '../test-utils/mock-data/store'
+import { setDefaultTestTime } from '../test-utils'
+import DateTimeOptions from '../../lib/components/form/call-taker/date-time-picker'
+
+describe('components > form > call-taker > date time options', () => {
+ beforeEach(setDefaultTestTime)
+
+ // TODO: generate each of these with a method?
+ it('should render', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '12:34' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+ it('should correctly handle "12p"', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '12p' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+ it('should correctly handle "12a"', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '12a' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+ it('should correctly handle "1335"', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '1335' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+ it('should correctly handle "135p"', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '135p' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+ it('should correctly handle "133"', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '133' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+ it('should correctly handle "133p"', () => {
+ const mockState = getMockInitialState()
+
+ expect(
+ mockWithProvider(
+ DateTimeOptions,
+ { date: '2022-11-17', time: '133p' },
+ mockState
+ ).snapshot()
+ ).toMatchSnapshot()
+ })
+})
diff --git a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap
index dd3959953..d21c901da 100644
--- a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap
+++ b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap
@@ -22,208 +22,46 @@ exports[`components > viewers > stop viewer should render countdown times after
}
}
>
-
-
+
- viewers > stop viewer should render countdown times after
"wrapRichTextChunksInFragment": undefined,
}
}
- rememberStop={[Function]}
- setHoveredStop={[Function]}
- setLocation={[Function]}
- setMainPanelContent={[Function]}
- showNearbyStops={true}
- stopData={
- Object {
- "agencyName": "TriMet",
- "amenities": Array [
- "Crosswalk near stop",
- "Curb ramp near stop",
- "Pavement at back door",
- "Pavement at front door",
- "Schedule display",
- "Sidewalk at stop",
- "Traffic signal",
- ],
- "code": "9860",
- "desc": "Eastbound stop in Portland (Stop ID 9860)",
- "id": "TriMet:9860",
- "lat": 45.522919,
- "lon": -122.689717,
- "mode": "BUS",
- "name": "W Burnside & SW 18th",
- "routes": Array [
- Object {
- "agencyName": "TriMet",
- "id": "TriMet:20",
- "longName": "Burnside/Stark",
- "mode": "BUS",
- "shortName": "20",
- "sortOrder": 2600,
- "sortOrderSet": true,
- "type": 3,
+ >
+
-
@@ -39,15 +49,18 @@ class QueryRecordLayout extends Component {
}
}
-const mapStateToProps = (state, ownProps) => {
+const mapStateToProps = (state) => {
return {
timeFormat: getTimeFormat(state.otp.config)
}
}
-const {parseUrlQueryString} = formActions
+const { parseUrlQueryString } = formActions
const mapDispatchToProps = { parseUrlQueryString }
-const QueryRecord = connect(mapStateToProps, mapDispatchToProps)(QueryRecordLayout)
+const QueryRecord = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(QueryRecordLayout)
export default QueryRecord
diff --git a/lib/components/admin/styled.js b/lib/components/admin/styled.ts
similarity index 85%
rename from lib/components/admin/styled.js
rename to lib/components/admin/styled.ts
index 7ee01dbe4..755f78415 100644
--- a/lib/components/admin/styled.js
+++ b/lib/components/admin/styled.ts
@@ -1,7 +1,7 @@
import { Button as BsButton } from 'react-bootstrap'
-import styled, {css} from 'styled-components'
+import styled, { css } from 'styled-components'
-import Icon from '../util/icon'
+import { StyledIconWrapper } from '../util/styledIcon'
import DefaultCounter from './call-time-counter'
@@ -13,6 +13,7 @@ const GREEN = '#6B931B'
const PURPLE = '#8134D3'
const circleButtonStyle = css`
+ aspect-ratio: 1/1;
border: none;
border-radius: 50%;
box-shadow: 2px 2px 4px #000000;
@@ -24,10 +25,8 @@ const circleButtonStyle = css`
export const CallHistoryButton = styled.button`
${circleButtonStyle}
background-color: ${GREEN};
- height: 40px;
margin-left: 69px;
top: 140px;
- width: 40px;
`
export const CallTimeCounter = styled(DefaultCounter)`
@@ -51,19 +50,18 @@ export const ControlsContainer = styled.div`
export const FieldTripsButton = styled.button`
${circleButtonStyle}
background-color: ${PURPLE};
- height: 50px;
margin-left: 80px;
top: 190px;
- width: 50px;
`
+type ToggleCallButtonProps = {
+ callInProgress?: boolean
+}
-export const ToggleCallButton = styled.button`
+export const ToggleCallButton = styled.button`
${circleButtonStyle}
- background-color: ${props => props.callInProgress ? RED : BLUE};
- height: 80px;
+ background-color: ${(props) => (props.callInProgress ? RED : BLUE)};
margin-left: -8px;
top: 154px;
- width: 80px;
`
// Field Trip Windows Components
@@ -80,20 +78,18 @@ export const Container = styled.div`
`
export const Half = styled.div`
- width: 50%
+ width: 50%;
`
-export const CallRecordRow = styled.div`
-
-`
+export const CallRecordRow = styled.div``
export const CallRecordButton = styled.button`
display: flex;
- flexDirection: row;
+ flex-direction: row;
width: 100%;
`
-export const CallRecordIcon = styled(Icon)`
+export const CallRecordIcon = styled(StyledIconWrapper)`
margin-right: 3px;
padding-top: 4px;
`
@@ -108,7 +104,7 @@ export const FieldTripRecordButton = styled.button`
`
export const Full = styled.div`
- width: 100%
+ width: 100%;
`
export const FullWithMargin = styled(Full)`
@@ -145,7 +141,7 @@ export const Text = styled.span`
export const Val = styled.span`
:empty:before {
- color: #685C5C;
+ color: #685c5c;
content: 'N/A';
}
`
diff --git a/lib/components/admin/trip-status.js b/lib/components/admin/trip-status.js
index 833a82a1a..0770f7863 100644
--- a/lib/components/admin/trip-status.js
+++ b/lib/components/admin/trip-status.js
@@ -1,28 +1,23 @@
+/* eslint-disable react/prop-types */
+import { connect } from 'react-redux'
+import { format } from 'date-fns'
import { getTimeFormat } from '@opentripplanner/core-utils/lib/time'
-import moment from 'moment'
-import React, {Component} from 'react'
import { injectIntl } from 'react-intl'
-import { connect } from 'react-redux'
+import React, { Component } from 'react'
import * as fieldTripActions from '../../actions/field-trip'
import * as formActions from '../../actions/form'
-import { getTripFromRequest } from '../../util/call-taker'
+import { getTripFromRequest, parseDate } from '../../util/call-taker'
+import { Bold, Button, Full, Header, Para } from './styled'
import FieldTripStatusIcon from './field-trip-status-icon'
-import {
- Bold,
- Button,
- Full,
- Header,
- Para
-} from './styled'
class TripStatus extends Component {
- _formatTime = (time) => moment(time).format(this.props.timeFormat)
+ _formatTime = (time) => format(parseDate(time), this.props.timeFormat)
_onDeleteTrip = () => {
const { deleteRequestTripItineraries, intl, request, trip } = this.props
- if (!confirm('Are you sure you want to delete the planned trip?')) {
+ if (!window.confirm('Are you sure you want to delete the planned trip?')) {
return
}
deleteRequestTripItineraries(request, trip.id, intl)
@@ -31,7 +26,11 @@ class TripStatus extends Component {
_onPlanTrip = () => {
const { intl, outbound, planTrip, request, status, trip } = this.props
if (status && trip) {
- if (!confirm('Re-planning this trip will cause the trip planner to avoid the currently saved trip. Are you sure you want to continue?')) {
+ if (
+ !window.confirm(
+ 'Re-planning this trip will cause the trip planner to avoid the currently saved trip. Are you sure you want to continue?'
+ )
+ ) {
return
}
}
@@ -53,11 +52,7 @@ class TripStatus extends Component {
_renderTripStatus = () => {
const { trip } = this.props
if (!this._tripIsPlanned()) {
- return (
-
- No itineraries planned! Click Plan to plan trip.
-
- )
+ return No itineraries planned! Click Plan to plan trip.
}
return (
<>
@@ -66,15 +61,19 @@ class TripStatus extends Component {
{trip.createdBy} at {trip.timeStamp}
- View
- Delete
+
+ View
+
+
+ Delete
+
>
)
}
- render () {
- const {outbound, request, saveable} = this.props
+ render() {
+ const { outbound, request, saveable } = this.props
const {
arriveDestinationTime,
arriveSchoolTime,
@@ -93,27 +92,26 @@ class TripStatus extends Component {
{outbound ? 'Outbound' : 'Inbound'} trip
- Plan
-
+
+ Plan
+
+
Save
- From {start} to {end}
- {outbound
- ?
- Arriving at {this._formatTime(arriveDestinationTime)}
-
- : <>
+
+ From {start} to {end}
+
+ {outbound ? (
+ Arriving at {this._formatTime(arriveDestinationTime)}
+ ) : (
+ <>
- Leave at {this._formatTime(leaveDestinationTime)},{' '}
- due back at {this._formatTime(arriveSchoolTime)}
+ Leave at {this._formatTime(leaveDestinationTime)}, due back at{' '}
+ {this._formatTime(arriveSchoolTime)}
>
- }
+ )}
{this._renderTripStatus()}
)
@@ -140,6 +138,7 @@ const mapDispatchToProps = {
viewRequestTripItineraries: fieldTripActions.viewRequestTripItineraries
}
-export default connect(mapStateToProps, mapDispatchToProps)(
- injectIntl(TripStatus)
-)
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(injectIntl(TripStatus))
diff --git a/lib/components/app/app-frame.js b/lib/components/app/app-frame.js
deleted file mode 100644
index e40053ec3..000000000
--- a/lib/components/app/app-frame.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react'
-import { Col, Row } from 'react-bootstrap'
-
-import DesktopNav from './desktop-nav'
-import NotFound from './not-found'
-
-/**
- * This component defines the general application frame, to which
- * content and an optional sub-navigation component can be inserted.
- */
-const AppFrame = ({ children, SubNav }) => (
-
- {/* TODO: Do mobile view. */}
-
- {SubNav && }
-
-
-
- {children}
-
-
-
-
-)
-
-/**
- * Creates a simple wrapper component consisting of an AppFrame that surrounds
- * the provided component. (Displays "Content not found" if none provided.)
- */
-export function frame (Component) {
- return () => (
-
- {Component
- ?
- :
- }
-
- )
-}
-
-export default AppFrame
diff --git a/lib/components/app/app-frame.tsx b/lib/components/app/app-frame.tsx
new file mode 100644
index 000000000..e31d903c7
--- /dev/null
+++ b/lib/components/app/app-frame.tsx
@@ -0,0 +1,46 @@
+import { Col, Row } from 'react-bootstrap'
+import React, { ComponentType, FC, HTMLAttributes } from 'react'
+
+import DesktopNav from './desktop-nav'
+import NotFound from './not-found'
+
+interface Props extends HTMLAttributes {
+ SubNav?: ComponentType
+}
+
+/**
+ * This component defines the general application frame, to which
+ * content and an optional sub-navigation component can be inserted.
+ */
+const AppFrame = ({ children, SubNav }: Props): JSX.Element => (
+
+ {/* TODO: Do mobile view. */}
+
+ {SubNav && }
+ {/* Create a main region here so that the DesktopNav, which contains a "banner" landmark,
+ is not contained within the main or other landmark
+ (see https://dequeuniversity.com/rules/axe/4.3/landmark-banner-is-top-level?application=axe-puppeteer) */}
+
+
+
+
+ {children}
+
+
+
+
+
+)
+
+/**
+ * Creates a simple wrapper component consisting of an AppFrame that surrounds
+ * the provided component. (Displays "Content not found" if none provided.)
+ */
+export function frame(Component: ComponentType): FC {
+ const FramedComponent = () => (
+ {Component ? : }
+ )
+ return FramedComponent
+}
+
+export default AppFrame
diff --git a/lib/components/app/app-menu-item.tsx b/lib/components/app/app-menu-item.tsx
new file mode 100644
index 000000000..bab4f3632
--- /dev/null
+++ b/lib/components/app/app-menu-item.tsx
@@ -0,0 +1,101 @@
+import { ChevronDown } from '@styled-icons/fa-solid/ChevronDown'
+import { ChevronUp } from '@styled-icons/fa-solid/ChevronUp'
+import AnimateHeight from 'react-animate-height'
+import React, { Component, HTMLAttributes, KeyboardEvent } from 'react'
+
+interface Props extends HTMLAttributes {
+ href?: string
+ icon?: JSX.Element
+ onClick?: () => void
+ subItems?: JSX.Element[]
+ text: JSX.Element | string
+}
+
+interface State {
+ isExpanded: boolean
+}
+
+/**
+ * Helper method to find the element within the app menu at the given offset
+ * (e.g. previous or next) relative to the specified element.
+ * The query is limited to the app menu so that arrow navigation is contained within
+ * (tab navigation is not restricted).
+ */
+function getEntryRelativeTo(element: EventTarget, offset: 1 | -1): HTMLElement {
+ const entries = Array.from(
+ document.querySelectorAll('.app-menu a, .app-menu button')
+ )
+ const elementIndex = entries.indexOf(element as HTMLElement)
+ return entries[elementIndex + offset] as HTMLElement
+}
+
+/**
+ * Renders a single entry from the hamburger menu.
+ */
+export default class AppMenuItem extends Component {
+ state = {
+ isExpanded: false
+ }
+
+ _handleKeyDown = (e: KeyboardEvent): void => {
+ const { subItems } = this.props
+ const element = e.target as HTMLElement
+ switch (e.key) {
+ case 'ArrowLeft':
+ subItems && this.setState({ isExpanded: false })
+ break
+ case 'ArrowUp':
+ getEntryRelativeTo(element, -1)?.focus()
+ break
+ case 'ArrowRight':
+ subItems && this.setState({ isExpanded: true })
+ break
+ case 'ArrowDown':
+ getEntryRelativeTo(element, 1)?.focus()
+ break
+ case ' ':
+ // For links (tagName "A" uppercase), trigger link on space for consistency with buttons.
+ element.tagName === 'A' && element.click()
+ break
+ default:
+ }
+ }
+
+ _toggleSubmenu = (): void => {
+ this.setState({ isExpanded: !this.state.isExpanded })
+ }
+
+ render(): JSX.Element {
+ const { icon, id, onClick, subItems, text, ...otherProps } = this.props
+ const { isExpanded } = this.state
+ const Element = otherProps.href ? 'a' : 'button'
+ const containerId = `${id}-container`
+ return (
+ <>
+
+ {icon}
+ {text}
+ {subItems && (
+
+ {isExpanded ? : }
+
+ )}
+
+ {subItems && (
+
+
+ {subItems}
+
+
+ )}
+ >
+ )
+ }
+}
diff --git a/lib/components/app/app-menu.tsx b/lib/components/app/app-menu.tsx
index c87eae577..67fd810a2 100644
--- a/lib/components/app/app-menu.tsx
+++ b/lib/components/app/app-menu.tsx
@@ -1,49 +1,61 @@
-/* eslint-disable react/jsx-handler-names */
+import { Bus } from '@styled-icons/fa-solid/Bus'
import { connect } from 'react-redux'
-import { FormattedMessage, injectIntl, useIntl } from 'react-intl'
-import React, { Component, Fragment } from 'react'
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore
-import { MenuItem } from 'react-bootstrap'
+import { Envelope } from '@styled-icons/fa-regular/Envelope'
+import { ExternalLinkSquareAlt } from '@styled-icons/fa-solid/ExternalLinkSquareAlt'
+import { FormattedMessage, injectIntl } from 'react-intl'
+import { GlobeAmericas, MapMarked } from '@styled-icons/fa-solid'
+import { GraduationCap } from '@styled-icons/fa-solid/GraduationCap'
+import { History } from '@styled-icons/fa-solid/History'
+import { Undo } from '@styled-icons/fa-solid/Undo'
import { withRouter } from 'react-router'
-import qs from 'qs'
+import React, { Component, Fragment, useContext } from 'react'
import SlidingPane from 'react-sliding-pane'
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
-import VelocityTransitionGroup from 'velocity-react/velocity-transition-group'
import * as callTakerActions from '../../actions/call-taker'
import * as fieldTripActions from '../../actions/field-trip'
+import * as uiActions from '../../actions/ui'
+import { ComponentContext } from '../../util/contexts'
+import { getLanguageOptions } from '../../util/i18n'
import { isModuleEnabled, Modules } from '../../util/config'
-import { MainPanelContent, setMainPanelContent } from '../../actions/ui'
-import Icon from '../util/icon'
+import { MainPanelContent } from '../../actions/ui-constants'
+import { setMainPanelContent } from '../../actions/ui'
+import startOver from '../util/start-over'
+
+import AppMenuItem from './app-menu-item'
type AppMenuProps = {
+ activeLocale: string
callTakerEnabled?: boolean
extraMenuItems?: menuItem[]
fieldTripEnabled?: boolean
+ // Typescript TODO language options based on configLanguage.
+ languageOptions: Record | null
location: { search: string }
mailablesEnabled?: boolean
+ popupTarget: string
reactRouterConfig?: { basename: string }
resetAndToggleCallHistory?: () => void
resetAndToggleFieldTrips?: () => void
- setMainPanelContent: (panel: number) => void
+ setLocale: (locale: string) => void
+ setMainPanelContent: (panel: number | null) => void
+ setPopupContent: (url: string) => void
toggleMailables: () => void
}
type AppMenuState = {
- expandedSubmenus: Record
isPaneOpen: boolean
}
type menuItem = {
- children: menuItem[]
- href: string
- iconType: string
- iconUrl: string
+ children?: menuItem[]
+ href?: string
+ iconType: string | JSX.Element
+ iconUrl?: string
id: string
- label: string
+ isSelected?: boolean
+ label: string | JSX.Element
+ lang?: string
+ onClick?: () => void
subMenuDivider: boolean
}
@@ -54,6 +66,12 @@ class AppMenu extends Component<
AppMenuProps & WrappedComponentProps & RouteComponentProps,
AppMenuState
> {
+ static contextType = ComponentContext
+
+ state = {
+ isPaneOpen: false
+ }
+
_showRouteViewer = () => {
this.props.setMainPanelContent(MainPanelContent.ROUTE_VIEWER)
this._togglePane()
@@ -62,38 +80,30 @@ class AppMenu extends Component<
_startOver = () => {
const { location, reactRouterConfig } = this.props
const { search } = location
- let startOverUrl = '/'
- if (reactRouterConfig && reactRouterConfig.basename) {
- startOverUrl += reactRouterConfig.basename
- }
- // If search contains sessionId, preserve this so that the current session
- // is not lost when the page reloads.
- if (search) {
- const params = qs.parse(search, { ignoreQueryPrefix: true })
- const { sessionId } = params
- if (sessionId) {
- startOverUrl += `?${qs.stringify({ sessionId })}`
- }
- }
- window.location.href = startOverUrl
+ window.location.href = startOver(reactRouterConfig?.basename, search)
+ }
+
+ _triggerPopup = () => {
+ const { popupTarget, setPopupContent } = this.props
+ setPopupContent(popupTarget)
+ this._togglePane()
}
_togglePane = () => {
- const { isPaneOpen } = this.state ?? false
+ const { isPaneOpen } = this.state
this.setState({ isPaneOpen: !isPaneOpen })
}
- _toggleSubmenu = (id: string) => {
- let { expandedSubmenus } = this.state
- if (!expandedSubmenus) {
- expandedSubmenus = {}
- }
+ _showTripPlanner = () => {
+ this.props.setMainPanelContent(null)
+ this._togglePane()
+ }
- const currentlyOpen = expandedSubmenus[id] || false
- this.setState({ expandedSubmenus: { [id]: !currentlyOpen } })
+ _handleSkipNavigation = () => {
+ document.querySelector('main')?.focus()
}
- _addExtraMenuItems = (menuItems?: menuItem[]) => {
+ _addExtraMenuItems = (menuItems?: menuItem[] | null) => {
return (
menuItems &&
menuItems.map((menuItem) => {
@@ -103,62 +113,43 @@ class AppMenu extends Component<
iconType,
iconUrl,
id,
+ isSelected,
label: configLabel,
+ lang,
+ onClick,
subMenuDivider
} = menuItem
- const { expandedSubmenus } = this.state ?? {}
const { intl } = this.props
- const isSubmenuExpanded = expandedSubmenus?.[id]
-
const localizationId = `config.menuItems.${id}`
- const localizedLabel = intl.formatMessage({ id: localizationId })
+ const localizedLabel = intl.formatMessage({
+ // Add the string id as the default message to limit error messages.
+ defaultMessage: localizationId,
+ id: localizationId
+ })
// Override the config label if a localized label exists
const label =
localizedLabel === localizationId ? configLabel : localizedLabel
- if (children) {
- return (
-
-
-
- {isSubmenuExpanded && (
-
- }
-
- )
- }
-}
-
-// connect to the redux store
-const mapStateToProps = (state, ownProps) => {
- const showUserSettings = getShowUserSettings(state)
- return {
- activeSearch: getActiveSearch(state),
- currentQuery: state.otp.currentQuery,
- mainPanelContent: state.otp.ui.mainPanelContent,
- showUserSettings
- }
-}
-
-const mapDispatchToProps = { }
-
-export default connect(mapStateToProps, mapDispatchToProps)(DefaultMainPanel)
diff --git a/lib/components/app/desktop-nav.tsx b/lib/components/app/desktop-nav.tsx
index e5c60106d..b5c549144 100644
--- a/lib/components/app/desktop-nav.tsx
+++ b/lib/components/app/desktop-nav.tsx
@@ -1,7 +1,10 @@
import { connect } from 'react-redux'
-import { Nav, Navbar } from 'react-bootstrap'
+import { FormattedMessage } from 'react-intl'
+import { Nav, Navbar, NavItem } from 'react-bootstrap'
import React from 'react'
+import styled from 'styled-components'
+import * as uiActions from '../../actions/ui'
import { accountLinks, getAuth0Config } from '../../util/auth'
import { DEFAULT_APP_TITLE } from '../../util/constants'
import NavLoginButtonAuth0 from '../user/nav-login-button-auth0'
@@ -9,6 +12,20 @@ import NavLoginButtonAuth0 from '../user/nav-login-button-auth0'
import AppMenu from './app-menu'
import LocaleSelector from './locale-selector'
import ViewSwitcher from './view-switcher'
+
+const NavItemOnLargeScreens = styled(NavItem)`
+ display: block;
+ @media (max-width: 768px) {
+ display: none !important;
+ }
+`
+// Typscript TODO: otpConfig type
+export type Props = {
+ otpConfig: any
+ popupTarget?: string
+ setPopupContent: (url: string) => void
+}
+
/**
* 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.
@@ -16,72 +33,56 @@ import ViewSwitcher from './view-switcher'
* The `branding` and `title` parameters in config.yml are handled
* and shown in this order in the navigation bar:
* 1. If `branding` is defined, it is shown, and no title is displayed.
+ * (The title is still rendered for screen readers and browsers that lack image support.)
* 2. If `branding` is not defined but if `title` is, then `title` is shown.
* 3. If neither is defined, just show 'OpenTripPlanner' (DEFAULT_APP_TITLE).
*
* TODO: merge with the mobile navigation bar.
*/
-// Typscript TODO: otpConfig type
-export type otpConfigType = {
- otpConfig: any
-}
-
-const DesktopNav = ({ otpConfig }: otpConfigType) => {
+const DesktopNav = ({ otpConfig, popupTarget, setPopupContent }: Props) => {
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.
- let brandingOrTitle
- if (branding) {
- brandingOrTitle = (
-
- )
- } else {
- brandingOrTitle = (
-
- {title}
-
- )
- }
-
return (
-
- {/* Required to allow the hamburger button to be clicked */}
-
-
- {/* TODO: Reconcile CSS class and inline style. */}
-
+
+
+
+
-
+
+ {/* A title is always rendered (e.g.for screen readers)
+ but is visually-hidden if a branding icon is used. */}
+
+ ))}
+
+ ) : null
}
// Typescript TODO: type state properly
const mapStateToProps = (state: any) => {
return {
- locale: state.otp.ui.locale,
- loggedInUser: state.user.loggedInUser
+ languageOptions: getLanguageOptions(state.otp.config.language),
+ locale: state.otp.ui.locale
}
}
const mapDispatchToProps = {
- createOrUpdateUser: userActions.createOrUpdateUser,
setLocale: uiActions.setLocale
}
-const connector = connect(mapStateToProps, mapDispatchToProps)
-export default connector(LocaleSelector)
+export default connect(mapStateToProps, mapDispatchToProps)(LocaleSelector)
diff --git a/lib/components/app/not-found.js b/lib/components/app/not-found.js
index d31c6524c..9ba0c6aad 100644
--- a/lib/components/app/not-found.js
+++ b/lib/components/app/not-found.js
@@ -1,9 +1,10 @@
-import React from 'react'
import { Alert } from 'react-bootstrap'
+import { ExclamationTriangle } from '@styled-icons/fa-solid/ExclamationTriangle'
import { FormattedMessage } from 'react-intl'
+import React from 'react'
import styled from 'styled-components'
-import Icon from '../util/icon'
+import { IconWithText } from '../util/styledIcon'
const StyledAlert = styled(Alert)`
margin-top: 25px;
@@ -13,13 +14,14 @@ const StyledAlert = styled(Alert)`
* Displays a not-found alert if some content is not found.
*/
const NotFound = () => (
-
+
-
-
+
+
+
-
+
)
diff --git a/lib/components/app/popup.tsx b/lib/components/app/popup.tsx
new file mode 100644
index 000000000..f7f2b089c
--- /dev/null
+++ b/lib/components/app/popup.tsx
@@ -0,0 +1,59 @@
+import { Modal } from 'react-bootstrap'
+import { useIntl } from 'react-intl'
+import coreUtils from '@opentripplanner/core-utils'
+import React, { useEffect } from 'react'
+
+import PageTitle from '../util/page-title'
+
+type Props = {
+ content?: {
+ appendLocale?: boolean
+ id?: string
+ modal?: boolean
+ url?: string
+ }
+ hideModal: () => void
+}
+
+const isMobile = coreUtils.ui.isMobile()
+
+/**
+ * This component renders a bootstrap modal featuring a URL passed in via a content object.
+ * On mobile, the link is opened in a popup window.
+ *
+ * The modal is automatically shown once a valid URL is passed. A callback is provided for when
+ * the modal is closed by clicking on the background.
+ */
+const PopupWrapper = ({ content, hideModal }: Props): JSX.Element | null => {
+ const intl = useIntl()
+
+ const { appendLocale, id, modal, url } = content || {}
+
+ const useIframe = !isMobile && modal
+ const shown = !!url
+
+ // appendLocale is true by default, so undefined is true
+ const compiledUrl = `${url}${
+ appendLocale !== false ? intl.defaultLocale : ''
+ }`
+
+ useEffect(() => {
+ if (!useIframe && shown) {
+ window.open(compiledUrl, '_blank')
+ hideModal()
+ }
+ }, [compiledUrl, hideModal, useIframe, shown])
+
+ if (!compiledUrl || !useIframe) return null
+
+ const title = intl.formatMessage({ id: `config.popups.${id}` })
+
+ return (
+
+
+
+
+ )
+}
+
+export default PopupWrapper
diff --git a/lib/components/app/print-layout.js b/lib/components/app/print-layout.js
deleted file mode 100644
index 15efcf4b9..000000000
--- a/lib/components/app/print-layout.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import PrintableItinerary from '@opentripplanner/printable-itinerary'
-import PropTypes from 'prop-types'
-import React, { Component } from 'react'
-import { Button } from 'react-bootstrap'
-import { FormattedMessage } from 'react-intl'
-import { connect } from 'react-redux'
-
-import { parseUrlQueryString } from '../../actions/form'
-import { routingQuery } from '../../actions/api'
-import DefaultMap from '../map/default-map'
-import TripDetails from '../narrative/connected-trip-details'
-import { ComponentContext } from '../../util/contexts'
-import Icon from '../util/icon'
-import SpanWithSpace from '../util/span-with-space'
-import { addPrintViewClassToRootHtml, clearClassFromRootHtml } from '../../util/print'
-import { getActiveItinerary } from '../../util/state'
-
-class PrintLayout extends Component {
- static propTypes = {
- itinerary: PropTypes.object,
- parseUrlQueryString: PropTypes.func
- }
-
- static contextType = ComponentContext
-
- constructor (props) {
- super(props)
- this.state = {
- mapVisible: true
- }
- }
-
- _toggleMap = () => {
- this.setState({ mapVisible: !this.state.mapVisible })
- }
-
- _print = () => {
- window.print()
- }
-
- componentDidMount () {
- const { location, parseUrlQueryString } = this.props
- // Add print-view class to html tag to ensure that iOS scroll fix only applies
- // to non-print views.
- addPrintViewClassToRootHtml()
- // Parse the URL query parameters, if present
- if (location && location.search) {
- parseUrlQueryString()
- }
- }
-
- componentWillUnmount () {
- clearClassFromRootHtml()
- }
-
- render () {
- const { config, itinerary } = this.props
- const { LegIcon } = this.context
-
- return (
-
- {/* The header bar, including the Toggle Map and Print buttons */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* The map, if visible */}
- {this.state.mapVisible &&
-
-
-
- }
-
- {/* The main itinerary body */}
- {itinerary &&
- <>
-
-
- >
- }
-
- )
- }
-}
-
-// connect to the redux store
-
-const mapStateToProps = (state, ownProps) => {
- return {
- config: state.otp.config,
- itinerary: getActiveItinerary(state)
- }
-}
-
-const mapDispatchToProps = {
- parseUrlQueryString,
- routingQuery
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(PrintLayout)
diff --git a/lib/components/app/print-layout.tsx b/lib/components/app/print-layout.tsx
new file mode 100644
index 000000000..faba01d0c
--- /dev/null
+++ b/lib/components/app/print-layout.tsx
@@ -0,0 +1,171 @@
+import { Button } from 'react-bootstrap'
+import { connect } from 'react-redux'
+import { FormattedMessage, injectIntl, IntlShape } from 'react-intl'
+import { Itinerary } from '@opentripplanner/types'
+import { Map } from '@styled-icons/fa-solid/Map'
+import { Print } from '@styled-icons/fa-solid/Print'
+import { Times } from '@styled-icons/fa-solid/Times'
+// @ts-expect-error not typescripted yet
+import PrintableItinerary from '@opentripplanner/printable-itinerary'
+import React, { Component } from 'react'
+
+import * as apiActions from '../../actions/api'
+import * as formActions from '../../actions/form'
+import {
+ addPrintViewClassToRootHtml,
+ clearClassFromRootHtml
+} from '../../util/print'
+import { ComponentContext } from '../../util/contexts'
+import { getActiveItinerary, getActiveSearch } from '../../util/state'
+import { IconWithText } from '../util/styledIcon'
+import { summarizeQuery } from '../form/user-settings-i18n'
+import DefaultMap from '../map/default-map'
+import PageTitle from '../util/page-title'
+import SpanWithSpace from '../util/span-with-space'
+import TripDetails from '../narrative/connected-trip-details'
+
+type Props = {
+ // TODO: Typescript activeSearch type
+ activeSearch: any
+ // TODO: Typescript config type
+ config: any
+ currentQuery: any
+ intl: IntlShape
+ itinerary: Itinerary
+ location?: { search?: string }
+ parseUrlQueryString: (params?: any, source?: string) => any
+ // TODO: Typescript user type
+ user: any
+}
+
+type State = {
+ mapVisible?: boolean
+}
+
+class PrintLayout extends Component {
+ static contextType = ComponentContext
+
+ constructor(props: Props) {
+ super(props)
+ this.state = {
+ mapVisible: true
+ }
+ }
+
+ _toggleMap = () => {
+ this.setState({ mapVisible: !this.state.mapVisible })
+ }
+
+ _print = () => {
+ window.print()
+ }
+
+ _close = () => {
+ window.location.replace(String(window.location).replace('print/', ''))
+ }
+
+ componentDidMount() {
+ const { itinerary, location, parseUrlQueryString } = this.props
+
+ // Add print-view class to html tag to ensure that iOS scroll fix only applies
+ // to non-print views.
+ addPrintViewClassToRootHtml()
+ // Parse the URL query parameters, if present
+ if (!itinerary && location && location.search) {
+ parseUrlQueryString()
+ }
+
+ // TODO: use currentQuery to pan/zoom to the correct part of the map
+ }
+
+ componentWillUnmount() {
+ clearClassFromRootHtml()
+ }
+
+ render() {
+ const { activeSearch, config, intl, itinerary, user } = this.props
+ const { LegIcon } = this.context
+ const printVerb = intl.formatMessage({ id: 'common.forms.print' })
+
+ return (
+
+
+ {/* The header bar, including the Toggle Map and Print buttons */}
+
+
+
+
+
+
+
+
+
+
+
+ {printVerb}
+
+
+
+
+
+
+
+
+
+
+
+ {/* The map, if visible */}
+ {this.state.mapVisible && (
+
+ {/* FIXME: Improve reframing/setting map bounds when itinerary is received. */}
+
+
+ )}
+
+ {/* The main itinerary body */}
+ {itinerary && (
+ <>
+
+
+ >
+ )}
+
+ )
+ }
+}
+
+// connect to the redux store
+
+// TODO: Typescript state
+const mapStateToProps = (state: any) => {
+ const activeSearch = getActiveSearch(state)
+ const { localUser, loggedInUser } = state.user
+ const user = loggedInUser || localUser
+ return {
+ activeSearch,
+ config: state.otp.config,
+ currentQuery: state.otp.currentQuery,
+ itinerary: getActiveItinerary(state) as Itinerary,
+ user
+ }
+}
+
+const mapDispatchToProps = {
+ parseUrlQueryString: formActions.parseUrlQueryString,
+ routingQuery: apiActions.routingQuery
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(injectIntl(PrintLayout))
diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js
index d0150dbfd..c8c056b0a 100644
--- a/lib/components/app/responsive-webapp.js
+++ b/lib/components/app/responsive-webapp.js
@@ -6,6 +6,7 @@ import { connect } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import { createHashHistory } from 'history'
import { injectIntl, IntlProvider } from 'react-intl'
+import { MapProvider } from 'react-map-gl'
import { Route, Switch, withRouter } from 'react-router'
import coreUtils from '@opentripplanner/core-utils'
import isEqual from 'lodash.isequal'
@@ -15,14 +16,13 @@ import React, { Component } from 'react'
import * as authActions from '../../actions/auth'
import * as callTakerActions from '../../actions/call-taker'
-import * as configActions from '../../actions/config'
import * as formActions from '../../actions/form'
import * as locationActions from '../../actions/location'
import * as mapActions from '../../actions/map'
import * as uiActions from '../../actions/ui'
-import { AUTH0_AUDIENCE, AUTH0_SCOPE, URL_ROOT } from '../../util/constants'
+import { AUTH0_AUDIENCE, AUTH0_SCOPE } from '../../util/constants'
import { ComponentContext } from '../../util/contexts'
-import { getActiveItinerary, getTitle } from '../../util/state'
+import { getActiveItinerary } from '../../util/state'
import { getAuth0Config } from '../../util/auth'
import { getDefaultLocale } from '../../util/i18n'
import BeforeSignInScreen from '../user/before-signin-screen'
@@ -31,8 +31,11 @@ import MobileMain from '../mobile/main'
import printRoutes from '../../util/webapp-print-routes'
import webAppRoutes from '../../util/webapp-routes'
import withLoggedInUserSupport from '../user/with-logged-in-user-support'
+import withMap from '../map/with-map'
import DesktopNav from './desktop-nav'
+import PopupWrapper from './popup'
+import SessionTimeout from './session-timeout'
const { isMobile } = coreUtils.ui
@@ -57,14 +60,12 @@ class ResponsiveWebapp extends Component {
initZoomOnLocate,
intl,
location,
+ map,
matchContentToUrl,
query,
setLocationToCurrent,
- setMapCenter,
- setMapZoom,
- title
+ setMapCenter
} = this.props
- document.title = title
const urlParams = coreUtils.query.getUrlParams()
const newSearchId = urlParams.ui_activeSearch
// Determine if trip is being replanned by checking the active search ID
@@ -93,17 +94,14 @@ class ResponsiveWebapp extends Component {
// if in mobile mode and from field is not set, use current location as from and recenter map
if (isMobile() && query.from === null) {
setLocationToCurrent({ locationType: 'from' }, intl)
- setMapCenter(pt)
- if (initZoomOnLocate) {
- setMapZoom({ zoom: initZoomOnLocate })
- }
+ setMapCenter(map, pt, initZoomOnLocate)
}
}
// If the path changes (e.g., via a back button press) check whether the
// main content needs to switch between, for example, a viewer and a search.
if (!isEqual(location.pathname, prevProps.location.pathname)) {
// console.log('url changed to', location.pathname)
- matchContentToUrl(location)
+ matchContentToUrl(map, location)
}
// Check for change between ITINERARY and PROFILE routingTypes
@@ -131,14 +129,13 @@ class ResponsiveWebapp extends Component {
initializeModules,
intl,
location,
+ map,
matchContentToUrl,
parseUrlQueryString,
- receivedPositionResponse,
- title
+ receivedPositionResponse
} = this.props
// Add on back button press behavior.
window.addEventListener('popstate', handleBackButtonPress)
- document.title = title
// If a URL is detected without hash routing (e.g., http://localhost:9966?sessionId=test),
// window.location.search will have a value. In this case, we need to redirect to the URL root with the
@@ -149,7 +146,7 @@ class ResponsiveWebapp extends Component {
if (search) {
const searchParams = qs.parse(search, { ignoreQueryPrefix: true })
if (!(searchParams.code && searchParams.state)) {
- window.location.href = `${URL_ROOT}/#/${search}`
+ window.location.href = `${window.location.origin}/#/${search}`
return
}
}
@@ -162,7 +159,10 @@ class ResponsiveWebapp extends Component {
navigator.geolocation.watchPosition(
// On success
(position) => {
- receivedPositionResponse({ position })
+ // This object cloning is required to be allowed to read the position info twice
+ // on webkit browsers.
+ // See https://github.com/opentripplanner/otp-react-redux/pull/697 for details
+ receivedPositionResponse({ position: { ...position } })
},
// On error
(error) => {
@@ -175,7 +175,7 @@ class ResponsiveWebapp extends Component {
// Handle routing to a specific part of the app (e.g. stop viewer) on page
// load. (This happens prior to routing request in case special routerId is
// set from URL.)
- matchContentToUrl(location)
+ matchContentToUrl(map, location)
if (location && location.search) {
// Set search params and plan trip if routing enabled and a query exists
// in the URL.
@@ -190,20 +190,25 @@ class ResponsiveWebapp extends Component {
window.removeEventListener('popstate', this.props.handleBackButtonPress)
}
+ _hidePopup = () => {
+ const { setPopupContent } = this?.props
+ if (setPopupContent) setPopupContent(null)
+ }
+
renderDesktopView = () => {
+ const { sessionTimeoutSeconds } = this.props
const { MainControls, MainPanel, MapWindows } = this.context
+ const { popupContent } = this.props
return (
+
- {/*
- Note: the main tag provides a way for users of screen readers
- to skip to the primary page content.
- TODO: Find a better place.
- */}
-
+ {/* Note: the main tag provides a way for users of screen readers to skip to the
+ primary page content (tabindex = -1 needed for programmatic navigation skip). */}
+
@@ -214,16 +219,19 @@ class ResponsiveWebapp extends Component {
+ {sessionTimeoutSeconds && }
)
}
renderMobileView = () => {
+ const { popupContent } = this.props
+
return (
- // Needed for accessibility checks. TODO: Find a better place.
-
+ <>
+
-
+ >
)
}
@@ -234,8 +242,7 @@ class ResponsiveWebapp extends Component {
// connect to the redux store
-const mapStateToProps = (state, ownProps) => {
- const title = getTitle(state, ownProps.intl)
+const mapStateToProps = (state) => {
return {
activeItinerary: getActiveItinerary(state),
activeSearchId: state.otp.activeSearchId,
@@ -245,9 +252,10 @@ const mapStateToProps = (state, ownProps) => {
locale: state.otp.ui.locale,
mobileScreen: state.otp.ui.mobileScreen,
modeGroups: state.otp.config.modeGroups,
+ popupContent: state.otp.ui.popup,
query: state.otp.currentQuery,
searches: state.otp.searches,
- title
+ sessionTimeoutSeconds: state.otp.config.sessionTimeoutSeconds
}
}
@@ -260,15 +268,17 @@ const mapDispatchToProps = {
parseUrlQueryString: formActions.parseUrlQueryString,
receivedPositionResponse: locationActions.receivedPositionResponse,
setLocationToCurrent: mapActions.setLocationToCurrent,
- setMapCenter: configActions.setMapCenter,
- setMapZoom: configActions.setMapZoom
+ setMapCenter: mapActions.setMapCenter,
+ setPopupContent: uiActions.setPopupContent
}
const history = createHashHistory()
const WebappWithRouter = withRouter(
withLoggedInUserSupport(
- injectIntl(connect(mapStateToProps, mapDispatchToProps)(ResponsiveWebapp))
+ withMap(
+ injectIntl(connect(mapStateToProps, mapDispatchToProps)(ResponsiveWebapp))
+ )
)
)
@@ -313,6 +323,7 @@ class RouterWrapperWithAuth0 extends Component {
auth0Config,
components,
defaultLocale,
+ homeTimezone,
locale,
localizedMessages,
processSignIn,
@@ -324,45 +335,48 @@ class RouterWrapperWithAuth0 extends Component {
// Don't render anything until the locale/localized messages have been initialized.
const router = localizedMessages && (
-
-
+
-
- {routes.map((props, index) => {
- const {
- getContextComponent,
- shouldRenderWebApp,
- ...routerProps
- } = props
-
- return (
-
- : undefined
- }
- {...routerProps}
- />
- )
- })}
- {/* For any other route, simply return the web app. */}
- } />
-
-
-
+
+
+ {routes.map((props, index) => {
+ const {
+ getContextComponent,
+ shouldRenderWebApp,
+ ...routerProps
+ } = props
+
+ return (
+
+ : undefined
+ }
+ {...routerProps}
+ />
+ )
+ })}
+ {/* For any other route, simply return the web app. */}
+ } />
+
+
+
+
)
@@ -375,7 +389,7 @@ class RouterWrapperWithAuth0 extends Component {
onLoginError={showLoginError}
onRedirectCallback={processSignIn}
onRedirecting={BeforeSignInScreen}
- redirectUri={URL_ROOT}
+ redirectUri={window.location.origin}
scope={AUTH0_SCOPE}
>
{router}
@@ -387,10 +401,11 @@ class RouterWrapperWithAuth0 extends Component {
}
const mapStateToWrapperProps = (state) => {
- const { persistence, reactRouter } = state.otp.config
+ const { homeTimezone, persistence, reactRouter } = state.otp.config
return {
auth0Config: getAuth0Config(persistence),
defaultLocale: getDefaultLocale(state.otp.config, state.user.loggedInUser),
+ homeTimezone,
locale: state.otp.ui.locale,
localizedMessages: state.otp.ui.localizedMessages,
routerConfig: reactRouter
diff --git a/lib/components/app/session-timeout.tsx b/lib/components/app/session-timeout.tsx
new file mode 100644
index 000000000..92c7e2bbb
--- /dev/null
+++ b/lib/components/app/session-timeout.tsx
@@ -0,0 +1,131 @@
+import { Button, Modal } from 'react-bootstrap'
+import { connect } from 'react-redux'
+import { FormattedMessage } from 'react-intl'
+import React, { Component } from 'react'
+
+import * as uiActions from '../../actions/ui'
+
+interface Props {
+ lastActionMillis: number
+ resetSessionTimeout: () => void
+ sessionTimeoutSeconds: number
+ startOverFromInitialUrl: () => void
+}
+
+interface State {
+ showTimeoutWarning: boolean
+ timeoutObject?: NodeJS.Timer
+ timeoutStartMillis: number
+}
+
+/**
+ * This component makes the current session timeout
+ * by displaying a timeout warning one minute before the timeout,
+ * and by reloading the initial URL if there is no user-initiated
+ * actions within the timeout window.
+ */
+class SessionTimeout extends Component {
+ state = {
+ showTimeoutWarning: false,
+ timeoutObject: undefined,
+ timeoutStartMillis: 0
+ }
+
+ componentDidMount() {
+ // Wait one second or so after loading before probing for changes
+ // so that initialization actions can complete.
+ // This just delays the start of the session timer, so that checks under `this.handleTimeoutWatch`
+ // don't get performed unnecessarily with all the stuff that normally occurs during a page load.
+ setTimeout(this.handleAfterInitialActions, 1500)
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.state.timeoutObject)
+ }
+
+ handleAfterInitialActions = () => {
+ this.setState({
+ timeoutObject: setInterval(this.handleTimeoutWatch, 10000),
+ timeoutStartMillis: new Date().valueOf()
+ })
+ }
+
+ handleTimeoutWatch = () => {
+ const { lastActionMillis, sessionTimeoutSeconds, startOverFromInitialUrl } =
+ this.props
+ // Ignore actions happening before the session timer is started.
+ if (lastActionMillis > this.state.timeoutStartMillis) {
+ const idleMillis = new Date().valueOf() - lastActionMillis
+ const secondsToTimeout = sessionTimeoutSeconds - idleMillis / 1000
+
+ if (secondsToTimeout < 0) {
+ // TODO: If OTP middleware persistence is enabled,
+ // log out the logged-in user before resetting the page.
+
+ // Reload initial URL (page state is lost after this point)
+ startOverFromInitialUrl()
+ } else {
+ // If session is going to expire, display warning dialog, don't otherwise.
+ // For session timeouts of more than 180 seconds, display warning within one minute.
+ // For timeouts shorter than that, set the warning to 1/3 of the session timeout.
+ const timeoutWarningSeconds =
+ sessionTimeoutSeconds >= 180 ? 60 : sessionTimeoutSeconds / 3
+
+ this.setState({
+ showTimeoutWarning:
+ secondsToTimeout >= 0 && secondsToTimeout <= timeoutWarningSeconds
+ })
+ }
+ }
+ }
+
+ handleKeepSession = () => {
+ this.setState({
+ showTimeoutWarning: false
+ })
+ this.props.resetSessionTimeout()
+ }
+
+ render() {
+ const { startOverFromInitialUrl } = this.props
+ const { showTimeoutWarning } = this.state
+ return showTimeoutWarning ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null
+ }
+}
+
+const mapStateToProps = (state: any) => {
+ const { config, lastActionMillis } = state.otp
+ const { sessionTimeoutSeconds } = config
+ return {
+ lastActionMillis,
+ sessionTimeoutSeconds
+ }
+}
+
+const mapDispatchToProps = {
+ resetSessionTimeout: uiActions.resetSessionTimeout,
+ startOverFromInitialUrl: uiActions.startOverFromInitialUrl
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SessionTimeout)
diff --git a/lib/components/app/view-switcher.js b/lib/components/app/view-switcher.js
deleted file mode 100644
index 77f2e4401..000000000
--- a/lib/components/app/view-switcher.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import React, { Component } from 'react'
-import PropTypes from 'prop-types'
-import { Button } from 'react-bootstrap'
-import { withRouter } from 'react-router'
-import { connect } from 'react-redux'
-import { FormattedMessage, injectIntl } from 'react-intl'
-
-import { MainPanelContent, setMainPanelContent } from '../../actions/ui'
-
-/**
- * This component is a switcher between
- * the main views of the application.
- */
-class ViewSwitcher extends Component {
- static propTypes = {
- activePanel: PropTypes.number,
- setMainPanelContent: PropTypes.func,
- sticky: PropTypes.bool
- }
-
- _showRouteViewer = () => {
- this.props.setMainPanelContent(MainPanelContent.ROUTE_VIEWER)
- }
- _showTripPlanner = () => {
- this.props.setMainPanelContent(null)
- }
-
- render () {
- const { activePanel, intl, sticky } = this.props
-
- return (
-
-
-
-
-
-
-
-
- )
- }
-}
-
-// connect to the redux store
-
-const mapStateToProps = (state, ownProps) => {
- const {mainPanelContent} = state.otp.ui
-
- // Reverse the ID to string mapping
- let activePanel = Object.entries(MainPanelContent).find(
- (keyValuePair) => keyValuePair[1] === mainPanelContent
- )
- // activePanel is array of form [string, ID]
- // The trip planner has id null
- activePanel = (activePanel && activePanel[1]) || null
-
- return {
- activePanel
- }
-}
-
-const mapDispatchToProps = {
- setMainPanelContent
-}
-
-export default withRouter(connect(mapStateToProps, mapDispatchToProps)(injectIntl(ViewSwitcher)))
diff --git a/lib/components/app/view-switcher.tsx b/lib/components/app/view-switcher.tsx
new file mode 100644
index 000000000..d24615e13
--- /dev/null
+++ b/lib/components/app/view-switcher.tsx
@@ -0,0 +1,108 @@
+import { Button } from 'react-bootstrap'
+import { connect } from 'react-redux'
+import { FormattedMessage, useIntl } from 'react-intl'
+import { useHistory } from 'react-router'
+import React from 'react'
+
+import { MainPanelContent } from '../../actions/ui-constants'
+import { setMainPanelContent } from '../../actions/ui'
+
+type Props = {
+ accountsActive: boolean
+ activePanel: number | null
+ setMainPanelContent: (payload: number | null) => void
+ sticky?: boolean
+}
+/**
+ * This component is a switcher between
+ * the main views of the application.
+ */
+const ViewSwitcher = ({
+ accountsActive,
+ activePanel,
+ setMainPanelContent,
+ sticky
+}: Props) => {
+ const history = useHistory()
+ const intl = useIntl()
+
+ const _showRouteViewer = () => {
+ setMainPanelContent(MainPanelContent.ROUTE_VIEWER)
+ }
+
+ const _showTripPlanner = () => {
+ if (accountsActive) {
+ // Go up to root while preserving query parameters
+ history.push('..' + history.location.search)
+ } else {
+ setMainPanelContent(null)
+ }
+ }
+
+ const tripPlannerActive = activePanel === null
+ const routeViewerActive = activePanel === MainPanelContent.ROUTE_VIEWER
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+// connect to the redux store
+
+const mapStateToProps = (state: any) => {
+ const { mainPanelContent } = state.otp.ui
+
+ // Reverse the ID to string mapping
+ const activePanelPair = Object.entries(MainPanelContent).find(
+ (keyValuePair) => keyValuePair[1] === mainPanelContent
+ )
+ // activePanel is array of form [string, ID]
+ // The trip planner has id null
+ const activePanel = (activePanelPair && activePanelPair[1]) || null
+
+ return {
+ // TODO: more reliable way of detecting these things, such as terms of storage page
+ accountsActive: state.router.location.pathname.includes('/account'),
+ activePanel
+ }
+}
+
+const mapDispatchToProps = {
+ setMainPanelContent
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(ViewSwitcher)
diff --git a/lib/components/form/add-place-button.js b/lib/components/form/add-place-button.js
deleted file mode 100644
index c2b6bf688..000000000
--- a/lib/components/form/add-place-button.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react'
-import { FormattedMessage } from 'react-intl'
-
-import Icon from '../util/icon'
-
-const AddPlaceButton = ({from, intermediatePlaces, onClick, to}) => {
- // Only permit adding intermediate place if from/to is defined.
- const maxPlacesDefined = intermediatePlaces.length >= 3
- const disabled = !from || !to || maxPlacesDefined
- return (
-
-
- {maxPlacesDefined
- ?
- : disabled
- ?
- :
- }
-
- )
-}
-
-export default AddPlaceButton
diff --git a/lib/components/form/add-place-button.tsx b/lib/components/form/add-place-button.tsx
new file mode 100644
index 000000000..5cecd5d11
--- /dev/null
+++ b/lib/components/form/add-place-button.tsx
@@ -0,0 +1,43 @@
+import { FormattedMessage } from 'react-intl'
+import { PlusCircle } from '@styled-icons/fa-solid/PlusCircle'
+import React from 'react'
+
+import { IconWithText } from '../util/styledIcon'
+
+type Props = {
+ from: unknown
+ intermediatePlaces: Array
+ onClick: () => void
+ to: unknown
+}
+
+const AddPlaceButton = ({
+ from,
+ intermediatePlaces,
+ onClick,
+ to
+}: Props): JSX.Element => {
+ // Only permit adding intermediate place if from/to is defined.
+ const maxPlacesDefined = intermediatePlaces.length >= 3
+ const disabled = !from || !to || maxPlacesDefined
+ return (
+
+
+ {maxPlacesDefined ? (
+
+ ) : disabled ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+export default AddPlaceButton
diff --git a/lib/components/form/batch-preferences.tsx b/lib/components/form/batch-preferences.tsx
index 90c4fe1a0..aa2371b96 100644
--- a/lib/components/form/batch-preferences.tsx
+++ b/lib/components/form/batch-preferences.tsx
@@ -2,17 +2,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// import {DropdownSelector} from '@opentripplanner/trip-form'
import { connect } from 'react-redux'
+import { injectIntl, IntlShape } from 'react-intl'
import React, { Component } from 'react'
+import { combinationFilter } from '../../util/combination-filter'
import { ComponentContext } from '../../util/contexts'
+import { getSupportedModes } from '../../util/i18n'
import { setQueryParam } from '../../actions/form'
+import { defaultModeOptions, Mode } from './mode-buttons'
import { StyledBatchPreferences } from './batch-styled'
+interface Props {
+ config: any
+ intl: IntlShape
+ modeOptions: Mode[]
+ query: any
+ setQueryParam: (newQueryParam: any) => void
+}
+
// TODO: Central type source
export type Combination = {
mode: string
params?: { [key: string]: number | string }
+ requiredModes?: string[]
}
export const replaceTransitMode =
@@ -25,11 +38,7 @@ export const replaceTransitMode =
return { ...combination, mode }
}
-class BatchPreferences extends Component<{
- config: any
- query: any
- setQueryParam: (newQueryParam: any) => void
-}> {
+class BatchPreferences extends Component {
static contextType = ComponentContext
/**
@@ -40,19 +49,18 @@ class BatchPreferences extends Component<{
* Typescript TODO: combinations and queryParams need types
*/
onQueryParamChange = (newQueryParams: any) => {
- const { config, query, setQueryParam } = this.props
- const disabledModes = query.disabledModes || []
+ const { config, modeOptions, query, setQueryParam } = this.props
+ const enabledModes =
+ query.enabledModes ||
+ modeOptions.filter((m) => !m.defaultUnselected).map((m) => m.mode)
const combinations = config.modes.combinations
- .filter((combination: Combination) => {
- const modesInCombination = combination.mode.split(',')
- return !modesInCombination.find((m) => disabledModes.includes(m))
- })
+ .filter(combinationFilter(enabledModes))
.map(replaceTransitMode(newQueryParams.mode))
setQueryParam({ ...newQueryParams, combinations })
}
render() {
- const { config, query } = this.props
+ const { config, intl, query } = this.props
const { ModeIcon } = this.context
return (
@@ -63,7 +71,7 @@ class BatchPreferences extends Component<{
onQueryParamChange={this.onQueryParamChange}
queryParams={query}
supportedCompanies={config.companies}
- supportedModes={config.modes}
+ supportedModes={getSupportedModes(config, intl)}
/>
{/*
FIXME: use these instead? They're currently cut off by the short
@@ -133,6 +141,7 @@ const mapStateToProps = (state: {
const { config, currentQuery } = state.otp
return {
config,
+ modeOptions: config.modes.modeOptions || defaultModeOptions,
query: currentQuery
}
}
@@ -141,4 +150,7 @@ const mapDispatchToProps = {
setQueryParam
}
-export default connect(mapStateToProps, mapDispatchToProps)(BatchPreferences)
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(injectIntl(BatchPreferences))
diff --git a/lib/components/form/batch-settings.tsx b/lib/components/form/batch-settings.tsx
index 20f87bbd9..7c89b07bc 100644
--- a/lib/components/form/batch-settings.tsx
+++ b/lib/components/form/batch-settings.tsx
@@ -1,17 +1,19 @@
/* eslint-disable react/prop-types */
+import { Cog } from '@styled-icons/fa-solid/Cog'
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 { Search } from '@styled-icons/fa-solid/Search'
+import { SyncAlt } from '@styled-icons/fa-solid/SyncAlt'
import coreUtils from '@opentripplanner/core-utils'
import React, { Component } from 'react'
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 { combinationFilter } from '../../util/combination-filter'
+import { getActiveSearch, hasValidLocation } from '../../util/state'
+import { getDefaultModes } from '../../util/itinerary'
+import { StyledIconWrapper } from '../util/styledIcon'
import {
BatchPreferencesContainer,
@@ -19,35 +21,29 @@ import {
MainSettingsRow,
PlanTripButton,
SettingsPreview,
- StyledDateTimePreview
+ StyledDateTimePreview,
+ StyledDateTimePreviewContainer
} 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 ModeButtons, { defaultModeOptions } from './mode-buttons'
import type { Combination } from './batch-preferences'
-
-/**
- * Simple utility to check whether a list of mode strings contains the provided
- * mode. This handles exact match and prefix/suffix matches (i.e., checking
- * 'BICYCLE' will return true if 'BICYCLE' or 'BICYCLE_RENT' is in the list).
- *
- * FIXME: This might need to be modified to be a bit looser in how it handles
- * the 'contains' check. E.g., we might not want to remove WALK,TRANSIT if walk
- * is turned off, but we DO want to remove it if TRANSIT is turned off.
- */
-function listHasMode(modes: string[], mode: string) {
- return modes.some((m: string) => mode.indexOf(m) !== -1)
-}
-
-function combinationHasAnyOfModes(combination: Combination, modes: string[]) {
- return combination.mode.split(',').some((m: string) => listHasMode(modes, m))
-}
+import type { Mode } from './mode-buttons'
const ModeButtonsFullWidthContainer = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 5px;
+ margin-top: 0px;
+
+ /*
-
-
+ ...}>
+
+
+
)
}
diff --git a/lib/components/form/call-taker/date-time-options.js b/lib/components/form/call-taker/date-time-options.js
deleted file mode 100644
index c4dba40ef..000000000
--- a/lib/components/form/call-taker/date-time-options.js
+++ /dev/null
@@ -1,243 +0,0 @@
-/* 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'
-
-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.
- */
-const SUPPORTED_TIME_FORMATS = [
- 'HH:mm:ss',
- 'h:mm:ss a',
- 'h:mm:ssa',
- 'h:mm a',
- 'h:mma',
- 'h:mm',
- 'HHmm',
- 'hmm',
- 'ha',
- 'h',
- 'HH:mm'
-].map((format) => `YYYY-MM-DDT${format}`)
-
-/**
- * Convert input moment object to date/time query params in OTP API format.
- * @param {[type]} [time=moment(] [description]
- * @return {[type]} [description]
- */
-function momentToQueryParams(time = moment()) {
- return {
- date: time.format(OTP_API_DATE_FORMAT),
- time: time.format(OTP_API_TIME_FORMAT)
- }
-}
-
-/**
- * Contains depart/arrive selector and time/date inputs for the admin-oriented
- * Call Taker form. A few unique features/behaviors to note:
- * - when "leave now" is selected the time/date will now stay up to date in the
- * form and query params
- * - the time input will interpret various time formats using moment.js so that
- * users can quickly type a shorthand value (5p) and have that be parsed into
- * the correct OTP format.
- * - when a user changes the date or time, "leave now" (if selected) will
- * automatically switch to "depart at".
-
- * @type {Object}
- */
-class DateTimeOptions extends Component {
- state = {
- timeInput: ''
- }
-
- componentDidMount() {
- if (this.props.departArrive === 'NOW') {
- this._startAutoRefresh()
- }
- }
-
- componentWillUnmount() {
- this._stopAutoRefresh()
- }
-
- 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
- // the user originally input (this helps avoid changing the time input while
- // the user is simultaneously updating it).
- if (prevProps.time !== time && !parsedTime.isSame(dateTime)) {
- this._updateTimeInput(dateTime)
- }
- // If departArrive has been changed to leave now, begin auto refresh.
- if (departArrive !== prevProps.departArrive) {
- if (departArrive === 'NOW') this._startAutoRefresh()
- else this._stopAutoRefresh()
- }
- }
-
- _updateTimeInput = (time = moment()) =>
- // If auto-updating time input (for leave now), use short 24-hr format to
- // avoid writing a value that is too long for the time input's width.
- this.setState({ timeInput: time.format('H:mm') })
-
- _startAutoRefresh = () => {
- const timer = window.setInterval(this._refreshDateTime, 1000)
- this.setState({ timer })
- }
-
- _stopAutoRefresh = () => {
- window.clearInterval(this.state.timer)
- this.setState({ timer: null })
- }
-
- _refreshDateTime = () => {
- const now = moment()
- this._updateTimeInput(now)
- const dateTimeParams = momentToQueryParams(now)
- // Update query param if the current time has changed (i.e., on minute ticks).
- if (dateTimeParams.time !== this.props.time) {
- this.props.setQueryParam(dateTimeParams)
- }
- }
-
- _setDepartArrive = (evt) => {
- const { value: departArrive } = evt.target
- if (departArrive === 'NOW') {
- const now = moment()
- // If setting to leave now, update date/time and start auto refresh to keep
- // form input in sync.
- this.props.setQueryParam({
- departArrive,
- ...momentToQueryParams(now)
- })
- if (!this.state.timer) {
- this._startAutoRefresh()
- }
- } else {
- // If set to depart at/arrive by, stop auto refresh.
- this._stopAutoRefresh()
- this.props.setQueryParam({ departArrive })
- }
- }
-
- handleDateChange = (evt) =>
- this.handleDateTimeChange({ date: evt.target.value })
-
- /**
- * Handler that should be used when date or time is manually updated. This
- * will also update the departArrive value if need be.
- */
- handleDateTimeChange = (params) => {
- const { departArrive: prevDepartArrive } = this.props
- // If previously set to leave now, change to depart at when time changes.
- if (prevDepartArrive === 'NOW') params.departArrive = 'DEPART'
- this.props.setQueryParam(params)
- }
-
- /**
- * Select input string when time input is focused by user (for quick changes).
- */
- handleTimeFocus = (evt) => evt.target.select()
-
- 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) => {
- 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 })
- }
-
- render() {
- const { departArrive, intl, onKeyDown } = this.props
- const { timeInput } = this.state
- const dateTime = this.dateTimeAsMoment()
- return (
- <>
-
- {intl.formatTime(dateTime)}
- }
- placement="bottom"
- trigger={['focus', 'hover']}
- >
-
-
-
- >
- )
- }
-}
-
-export default injectIntl(DateTimeOptions)
diff --git a/lib/components/form/call-taker/date-time-picker.tsx b/lib/components/form/call-taker/date-time-picker.tsx
new file mode 100644
index 000000000..1306fee76
--- /dev/null
+++ b/lib/components/form/call-taker/date-time-picker.tsx
@@ -0,0 +1,241 @@
+import { connect } from 'react-redux'
+import { format, toDate } from 'date-fns-tz'
+import { getCurrentTime } from '@opentripplanner/core-utils/lib/time'
+import { IntlShape, useIntl } from 'react-intl'
+import { isMatch, parse } from 'date-fns'
+import { OverlayTrigger, Tooltip } from 'react-bootstrap'
+import coreUtils from '@opentripplanner/core-utils'
+import React, { useEffect, useState } from 'react'
+
+const { getCurrentDate, OTP_API_DATE_FORMAT, OTP_API_TIME_FORMAT } =
+ coreUtils.time
+
+function getDepartureOptions(intl: IntlShape) {
+ 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 date-fns to parse the user's time input.
+ */
+const SUPPORTED_TIME_FORMATS = [
+ 'h:mmaaaaa',
+ 'h:mmaaaa',
+ 'hmmaaaaa',
+ 'haaaaa',
+ 'haaaa',
+ 'haaa',
+ 'hmmaaa',
+ 'hmmaaaa',
+ 'Hmm',
+ 'Hm',
+ 'H:mm',
+ 'H:m',
+ 'H',
+ 'HH:mm'
+]
+
+const safeFormat = (date: Date | string, time: string, options: any) => {
+ try {
+ return format(date, time, options)
+ } catch (e) {
+ console.warn(e)
+ }
+ return ''
+}
+
+type Props = {
+ date?: string
+ homeTimezone: string
+ onKeyDown: () => void
+ setQueryParam: ({
+ date,
+ departArrive,
+ time
+ }: {
+ date: string
+ departArrive: string
+ time: string
+ }) => void
+ time?: string
+ timeFormat: string
+}
+/**
+ * Contains depart/arrive selector and time/date inputs for the admin-oriented
+ * Call Taker form. A few unique features/behaviors to note:
+ * - when "leave now" is selected the time/date will now stay up to date in the
+ * form and query params
+ * - the time input will interpret various time formats so that
+ * users can quickly type a shorthand value (5p) and have that be parsed into
+ * the correct OTP format.
+ * - when a user changes the date or time, "leave now" (if selected) will
+ * automatically switch to "depart at".
+
+ * @type {Object}
+ */
+
+const DateTimeOptions = ({
+ date: initialDate,
+ homeTimezone,
+ onKeyDown,
+ setQueryParam,
+ time: initialTime,
+ timeFormat
+}: Props) => {
+ const [departArrive, setDepartArrive] = useState(
+ initialDate || initialTime ? 'DEPART' : 'NOW'
+ )
+ const [date, setDate] = useState(initialDate)
+ const [time, setTime] = useState(initialTime)
+
+ const intl = useIntl()
+
+ /**
+ * Parse a time input expressed in the agency time zone.
+ * @returns A date if the parsing succeeded, or null.
+ */
+ const parseInputAsTime = (
+ timeInput: string = getCurrentTime(homeTimezone),
+ date: string = getCurrentDate(homeTimezone)
+ ) => {
+ if (!timeInput) timeInput = getCurrentTime(homeTimezone)
+
+ // Match one of the supported time formats
+ const matchedTimeFormat = SUPPORTED_TIME_FORMATS.find((timeFormat) =>
+ isMatch(timeInput, timeFormat)
+ )
+ if (matchedTimeFormat) {
+ const resolvedDateTime = format(
+ parse(timeInput, matchedTimeFormat, new Date()),
+ 'HH:mm:ss'
+ )
+ return toDate(`${date}T${resolvedDateTime}`)
+ }
+ return ''
+ }
+
+ const dateTime = parseInputAsTime(time, date)
+
+ // Handler for setting the query parameters
+ useEffect(() => {
+ if (safeFormat(dateTime, OTP_API_DATE_FORMAT, {}) !== '' && setQueryParam) {
+ setQueryParam({
+ date: safeFormat(dateTime, OTP_API_DATE_FORMAT, {
+ timeZone: homeTimezone
+ }),
+ departArrive,
+ time: safeFormat(dateTime, OTP_API_TIME_FORMAT, {
+ timeZone: homeTimezone
+ })
+ })
+ }
+ }, [dateTime, departArrive, homeTimezone, setQueryParam])
+
+ // Handler for updating the time and date fields when NOW is selected
+ useEffect(() => {
+ if (departArrive === 'NOW') {
+ setTime(getCurrentTime(homeTimezone))
+ setDate(getCurrentDate(homeTimezone))
+ }
+ }, [departArrive, setTime, setDate, homeTimezone])
+
+ const unsetNow = () => {
+ if (departArrive === 'NOW') setDepartArrive('DEPART')
+ }
+
+ return (
+ <>
+
+
+ {safeFormat(dateTime, timeFormat, {
+ timeZone: homeTimezone
+ }) ||
+ // TODO: there doesn't seem to be an intl object present?
+ 'Invalid Time'}
+
+ }
+ placement="bottom"
+ trigger={['focus', 'hover']}
+ >
+ {
+ setTime(e.target.value)
+ unsetNow()
+ }}
+ onFocus={(e) => e.target.select()}
+ onKeyDown={onKeyDown}
+ style={{
+ fontSize: 'inherit',
+ lineHeight: '.8em',
+ marginLeft: '3px',
+ padding: '0px',
+ width: '50px'
+ }}
+ // Don't use intl.formatTime, so that users can enter time in 12hr or 24hr format at their leisure.
+ value={
+ time && time?.length > 1
+ ? time || format(dateTime, 'H:mm', { timeZone: homeTimezone })
+ : time
+ }
+ />
+
+ {
+ setDate(e.target.value)
+ unsetNow()
+ }}
+ onKeyDown={onKeyDown}
+ style={{
+ fontSize: '14px',
+ lineHeight: '1em',
+ outline: 'none',
+ width: '109px'
+ }}
+ type="date"
+ value={safeFormat(dateTime, OTP_API_DATE_FORMAT, {
+ timeZone: homeTimezone
+ })}
+ />
+ >
+ )
+}
+
+// connect to the redux store
+const mapStateToProps = (state: any) => {
+ const { dateTime, homeTimezone } = state.otp.config
+ return {
+ homeTimezone,
+ timeFormat: dateTime.timeFormat
+ }
+}
+
+export default connect(mapStateToProps)(DateTimeOptions)
diff --git a/lib/components/form/connect-location-field.js b/lib/components/form/connect-location-field.js
index d898865f2..ebe7c218b 100644
--- a/lib/components/form/connect-location-field.js
+++ b/lib/components/form/connect-location-field.js
@@ -1,13 +1,20 @@
/* eslint-disable react/prop-types */
+import { Briefcase } from '@styled-icons/fa-solid/Briefcase'
import { connect } from 'react-redux'
+import { Home } from '@styled-icons/fa-solid/Home'
import { injectIntl } from 'react-intl'
+import { MapMarker } from '@styled-icons/fa-solid/MapMarker'
import React from 'react'
import * as apiActions from '../../actions/api'
import * as locationActions from '../../actions/location'
-import { getActiveSearch, getShowUserSettings } from '../../util/state'
+import {
+ getActiveSearch,
+ getShowUserSettings,
+ hasValidLocation
+} from '../../util/state'
import { getUserLocations } from '../../util/user'
-import Icon from '../util/icon'
+import { StyledIconWrapper } from '../util/styledIcon'
/**
* Custom icon component that renders based on the user location icon prop.
@@ -16,9 +23,15 @@ 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
+ let FinalIcon = MapMarker
+ if (icon === 'work') FinalIcon = Briefcase
+ else if (icon === 'home') FinalIcon = Home
- return
+ return (
+
+
+
+ )
}
/**
@@ -48,6 +61,8 @@ export default function connectLocationField(
const activeSearch = getActiveSearch(state)
const query = activeSearch ? activeSearch.query : currentQuery
+ const isValid = hasValidLocation(query, ownProps.locationType)
+
// 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).
@@ -68,6 +83,7 @@ export default function connectLocationField(
const stateToProps = {
currentPosition,
geocoderConfig,
+ isValid,
layerColorMap: config.geocoder?.resultsColors || {},
nearbyStops,
sessionSearches,
diff --git a/lib/components/form/connected-location-field.js b/lib/components/form/connected-location-field.js
index e7026b59b..b30bdb305 100644
--- a/lib/components/form/connected-location-field.js
+++ b/lib/components/form/connected-location-field.js
@@ -9,6 +9,7 @@ import {
import LocationField from '@opentripplanner/location-field'
import styled from 'styled-components'
+import * as formActions from '../../actions/form'
import * as mapActions from '../../actions/map'
import connectLocationField from './connect-location-field'
@@ -17,9 +18,8 @@ const StyledLocationField = styled(LocationField)`
width: 100%;
${DropdownContainer} {
- display: table-cell;
- vertical-align: middle;
- width: 1%;
+ display: grid;
+ grid-template-columns: 30px 1fr 30px;
}
${FormGroup} {
@@ -29,9 +29,7 @@ const StyledLocationField = styled(LocationField)`
}
${Input} {
- display: table-cell;
padding: 6px 12px;
- width: 100%;
}
${InputGroup} {
@@ -39,9 +37,8 @@ const StyledLocationField = styled(LocationField)`
}
${InputGroupAddon} {
- display: table-cell;
- vertical-align: middle;
- width: 1%;
+ align-self: baseline;
+ justify-self: center;
}
${MenuItemA} {
@@ -55,7 +52,7 @@ const StyledLocationField = styled(LocationField)`
export default connectLocationField(StyledLocationField, {
actions: {
- clearLocation: mapActions.clearLocation,
+ clearLocation: formActions.clearLocation,
onLocationSelected: mapActions.onLocationSelected
},
includeLocation: true
diff --git a/lib/components/form/connected-settings-selector-panel.js b/lib/components/form/connected-settings-selector-panel.js
index d5e5472ab..0a2a148a2 100644
--- a/lib/components/form/connected-settings-selector-panel.js
+++ b/lib/components/form/connected-settings-selector-panel.js
@@ -1,9 +1,12 @@
-import React, { Component } from 'react'
+/* eslint-disable react/prop-types */
import { connect } from 'react-redux'
+import { injectIntl } from 'react-intl'
+import React, { Component } from 'react'
-import { setQueryParam } from '../../actions/form'
import { ComponentContext } from '../../util/contexts'
import { getShowUserSettings } from '../../util/state'
+import { getSupportedModes } from '../../util/i18n'
+import { setQueryParam } from '../../actions/form'
import { StyledSettingsSelectorPanel } from './styled'
import UserTripSettings from './user-trip-settings'
@@ -13,18 +16,12 @@ import UserTripSettings from './user-trip-settings'
class ConnectedSettingsSelectorPanel extends Component {
static contextType = ComponentContext
- render () {
- const {
- config,
- query,
- setQueryParam,
- showUserSettings
- } = this.props
+ render() {
+ const { config, intl, query, setQueryParam, showUserSettings } = this.props
const { ModeIcon } = this.context
-
return (
-
)
// Add tall class to account for vertical centering if there is only
// one line in the label (default is 2).
const addClass = messages.label.match(/\n/) ? '' : ' tall'
return (
-
+
)
}
@@ -63,14 +71,18 @@ class MobileLocationSearch extends Component {
const mapStateToProps = (state, ownProps) => {
return {
location: state.otp.currentQuery[ownProps.locationType],
- otherLocation: ownProps.type === 'from'
- ? state.otp.currentQuery.to
- : state.otp.currentQuery.from
+ otherLocation:
+ ownProps.type === 'from'
+ ? state.otp.currentQuery.to
+ : state.otp.currentQuery.from
}
}
const mapDispatchToProps = {
- setMobileScreen
+ setMobileScreen: uiActions.setMobileScreen
}
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(MobileLocationSearch))
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(injectIntl(MobileLocationSearch))
diff --git a/lib/components/mobile/main.js b/lib/components/mobile/main.js
index c9a7598f8..f9d914b01 100644
--- a/lib/components/mobile/main.js
+++ b/lib/components/mobile/main.js
@@ -1,20 +1,20 @@
-import React, { Component } from 'react'
-import PropTypes from 'prop-types'
import { connect } from 'react-redux'
+import PropTypes from 'prop-types'
+import React, { Component } from 'react'
-import * as uiActions from '../../actions/ui'
import { ComponentContext } from '../../util/contexts'
import { getActiveSearch } from '../../util/state'
+import { MainPanelContent, MobileScreens } from '../../actions/ui-constants'
+import { setMobileScreen } from '../../actions/ui'
import MobileDateTimeScreen from './date-time-screen'
-import MobileOptionsScreen from './options-screen'
import MobileLocationSearch from './location-search'
-import MobileWelcomeScreen from './welcome-screen'
+import MobileOptionsScreen from './options-screen'
+import MobilePatternViewer from './pattern-viewer'
+import MobileRouteViewer from './route-viewer'
import MobileStopViewer from './stop-viewer'
import MobileTripViewer from './trip-viewer'
-import MobileRouteViewer from './route-viewer'
-
-const { MainPanelContent, MobileScreens } = uiActions
+import MobileWelcomeScreen from './welcome-screen'
class MobileMain extends Component {
static propTypes = {
@@ -27,21 +27,16 @@ class MobileMain extends Component {
static contextType = ComponentContext
- componentDidUpdate (prevProps) {
- const {
- activeSearch,
- currentPosition,
- currentQuery,
- setMobileScreen
- } = this.props
+ componentDidUpdate(prevProps) {
+ const { activeSearch, currentPosition, currentQuery, setMobileScreen } =
+ this.props
// Check if we are in the welcome screen and both locations have been set OR
// auto-detect is denied and one location is set
if (
- prevProps.uiState.mobileScreen === MobileScreens.WELCOME_SCREEN && (
- (currentQuery.from && currentQuery.to) ||
- (!currentPosition.coords && (currentQuery.from || currentQuery.to))
- )
+ prevProps.uiState.mobileScreen === MobileScreens.WELCOME_SCREEN &&
+ ((currentQuery.from && currentQuery.to) ||
+ (!currentPosition.coords && (currentQuery.from || currentQuery.to)))
) {
// If so, advance to main search screen
setMobileScreen(MobileScreens.SEARCH_FORM)
@@ -54,7 +49,8 @@ class MobileMain extends Component {
}
}
- render () {
+ // eslint-disable-next-line complexity
+ render() {
const { MobileResultsScreen, MobileSearchScreen } = this.context
const { uiState } = this.props
@@ -62,12 +58,19 @@ class MobileMain extends Component {
if (uiState.mainPanelContent === MainPanelContent.ROUTE_VIEWER) {
return
}
+ if (uiState.mainPanelContent === MainPanelContent.PATTERN_VIEWER) {
+ return
+ }
// check for viewed stop
if (uiState.viewedStop) return
// check for viewed trip
- if (uiState.viewedTrip) return
+ if (
+ uiState.viewedTrip ||
+ uiState.mainPanelContent === MainPanelContent.TRIP_VIEWER
+ )
+ return
switch (uiState.mobileScreen) {
case MobileScreens.WELCOME_SCREEN:
@@ -77,22 +80,20 @@ class MobileMain extends Component {
return (
)
case MobileScreens.SEARCH_FORM:
// Render batch search screen if batch routing enabled. Otherwise,
// default to standard search screen.
- return (
-
- )
+ return
case MobileScreens.SET_FROM_LOCATION:
return (
)
@@ -100,7 +101,7 @@ class MobileMain extends Component {
return (
)
@@ -120,7 +121,7 @@ class MobileMain extends Component {
// connect to the redux store
-const mapStateToProps = (state, ownProps) => {
+const mapStateToProps = (state) => {
const { config, currentQuery, location, ui: uiState } = state.otp
const activeSearch = getActiveSearch(state)
return {
@@ -133,7 +134,7 @@ const mapStateToProps = (state, ownProps) => {
}
const mapDispatchToProps = {
- setMobileScreen: uiActions.setMobileScreen
+ setMobileScreen: setMobileScreen
}
export default connect(mapStateToProps, mapDispatchToProps)(MobileMain)
diff --git a/lib/components/mobile/mobile.css b/lib/components/mobile/mobile.css
index eaa89f452..5379bc4df 100644
--- a/lib/components/mobile/mobile.css
+++ b/lib/components/mobile/mobile.css
@@ -18,18 +18,29 @@
}
.otp .navbar .mobile-header {
- position: fixed;
- top: 5px;
+ align-items: center;
+ display: flex;
+ height: 50px;
+ justify-content: center;
left: 50px;
+ max-width: 90%;
+ position: fixed;
right: 50px;
text-align: center;
}
.otp .navbar .mobile-header-text {
color: white;
- padding-top: 9px;
- font-size: 18px;
display: inline-block;
+ font-size: 18px;
+ margin: 0;
+}
+
+@media (max-width: 768px) {
+ .otp .navbar .mobile-header-text {
+ margin-top: 2px;
+ word-break: break-all;
+ }
}
.otp .navbar .mobile-header-action {
diff --git a/lib/components/mobile/navigation-bar.js b/lib/components/mobile/navigation-bar.js
index ffc6ca2d2..569fd6d29 100644
--- a/lib/components/mobile/navigation-bar.js
+++ b/lib/components/mobile/navigation-bar.js
@@ -1,15 +1,17 @@
+import { ArrowLeft } from '@styled-icons/fa-solid/ArrowLeft'
import { connect } from 'react-redux'
import { Navbar } from 'react-bootstrap'
-import PropTypes, { any } from 'prop-types'
+import PropTypes from 'prop-types'
import React, { Component } from 'react'
+import * as uiActions from '../../actions/ui'
import { accountLinks, getAuth0Config } from '../../util/auth'
import { ComponentContext } from '../../util/contexts'
-import { setMobileScreen } from '../../actions/ui'
+import { StyledIconWrapper } from '../util/styledIcon'
import AppMenu from '../app/app-menu'
-import Icon from '../util/icon'
import LocaleSelector from '../app/locale-selector'
import NavLoginButtonAuth0 from '../../components/user/nav-login-button-auth0'
+import PageTitle from '../util/page-title'
class MobileNavigationBar extends Component {
static propTypes = {
@@ -18,7 +20,7 @@ class MobileNavigationBar extends Component {
configLanguages: PropTypes.object,
defaultMobileTitle: PropTypes.string,
headerAction: PropTypes.element,
- headerText: PropTypes.element,
+ headerText: PropTypes.string,
onBackClicked: PropTypes.func,
setMobileScreen: PropTypes.func,
showBackButton: PropTypes.bool
@@ -43,59 +45,49 @@ class MobileNavigationBar extends Component {
} = this.props
return (
-
-
-
- {showBackButton ? (
-
-
-
+
+
+
+
+ {showBackButton ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {headerText ? (
+
{headerText}
) : (
-
+
{defaultMobileTitle}
)}
-
-
+
-
- {headerText ? (
-
{headerText}
- ) : (
-
{defaultMobileTitle}
+ {headerAction && (
+
+
{headerAction}
+
)}
-
-
- {headerAction && (
-
-
{headerAction}
-
- )}
-
- {configLanguages &&
- // Ensure that > 1 valid language is defined
- Object.keys(configLanguages).filter(
- (key) => key !== 'allLanguages' && configLanguages[key].name
- ).length > 1 && (
-
+
+
+ {/* HACK: Normally, NavLoginButtonAuth0 should be inside a element,
+ however, in mobile mode, react-bootstrap's causes the
+ submenus of this component to be displayed full-screen-width,
+ and that behavior is not desired here. */}
+ {auth0Config && (
+
)}
- {/**
- * HACK: Normally, NavLoginButtonAuth0 should be inside a element,
- * however, in mobile mode, react-bootstrap's causes the
- * submenus of this component to be displayed full-screen-width,
- * and that behavior is not desired here.
- */}
- {auth0Config && (
-
- )}
-
+
+ {/* The map is less important semantically, so keyboard focus and screen readers
+ will focus on the route viewer first. The map will still appear first visually. */}
+
+
+
+
+
+ )
+ }
+}
+
+// connect to the redux store
+
+const mapDispatchToProps = {
+ setMainPanelContent: uiActions.setMainPanelContent,
+ setViewedRoute: uiActions.setViewedRoute
+}
+
+export default connect(
+ null,
+ mapDispatchToProps
+)(injectIntl(MobilePatternViewer))
diff --git a/lib/components/mobile/results-error.js b/lib/components/mobile/results-error.js
index 7c319e956..6de01d443 100644
--- a/lib/components/mobile/results-error.js
+++ b/lib/components/mobile/results-error.js
@@ -1,10 +1,11 @@
+import { ArrowLeft } from '@styled-icons/fa-solid/ArrowLeft'
+import { FormattedMessage } from 'react-intl'
import PropTypes from 'prop-types'
import React from 'react'
-import { FormattedMessage } from 'react-intl'
import styled from 'styled-components'
+import { IconWithText } from '../util/styledIcon'
import ErrorMessage from '../form/error-message'
-import Icon from '../util/icon'
import EditSearchButton from './edit-search-button'
@@ -15,19 +16,21 @@ import EditSearchButton from './edit-search-button'
const ResultsError = ({ className, error }) => (
+
+ {/* The map is less important semantically, so keyboard focus and screen readers
+ will focus on the route viewer first. The map will still appear first visually. */}
+
+
+ {/* The map is less important semantically, so keyboard focus and screen readers
+ will focus on the stop viewer first. The map will still appear first visually. */}
+
+
+
+
+
+ )
+}
+
+// connect to the redux store
+
+const mapDispatchToProps = {
+ setViewedStop: uiActions.setViewedStop
+}
+
+export default connect(null, mapDispatchToProps)(MobileStopViewer)
diff --git a/lib/components/mobile/trip-viewer.js b/lib/components/mobile/trip-viewer.js
deleted file mode 100644
index a21ab69c8..000000000
--- a/lib/components/mobile/trip-viewer.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import PropTypes from 'prop-types'
-import React, { Component } from 'react'
-import { FormattedMessage } from 'react-intl'
-import { connect } from 'react-redux'
-
-import TripViewer from '../viewers/trip-viewer'
-import DefaultMap from '../map/default-map'
-import { setViewedTrip } from '../../actions/ui'
-
-import MobileNavigationBar from './navigation-bar'
-import MobileContainer from './container'
-
-class MobileTripViewer extends Component {
- static propTypes = {
- setViewedTrip: PropTypes.func
- }
-
- _onBackClicked = () => { this.props.setViewedTrip(null) }
-
- render () {
- return (
-
- }
- onBackClicked={this._onBackClicked}
- showBackButton
- />
-
- {/* include map as fixed component */}
-
-
-
-
- {/* include TripViewer in embedded scrollable panel */}
-
+
+ {/* The map is less important semantically, so keyboard focus and screen readers
+ will focus on the route viewer first. The map will still appear first visually. */}
+
+
+
+
+
+ )
+}
+
+// connect to the redux store
+
+const mapDispatchToProps = {
+ setViewedTrip: uiActions.setViewedTrip
+}
+
+export default connect(null, mapDispatchToProps)(MobileTripViewer)
diff --git a/lib/components/mobile/welcome-screen.js b/lib/components/mobile/welcome-screen.js
deleted file mode 100644
index d20187639..000000000
--- a/lib/components/mobile/welcome-screen.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import PropTypes from 'prop-types'
-import React, { Component } from 'react'
-import { injectIntl } from 'react-intl'
-import { connect } from 'react-redux'
-
-import LocationField from '../form/connected-location-field'
-import DefaultMap from '../map/default-map'
-import { MobileScreens, setMobileScreen } from '../../actions/ui'
-import { setLocationToCurrent } from '../../actions/map'
-
-import MobileNavigationBar from './navigation-bar'
-import MobileContainer from './container'
-
-class MobileWelcomeScreen extends Component {
- static propTypes = {
- setLocationToCurrent: PropTypes.func,
- setMobileScreen: PropTypes.func
- }
-
- _toFieldClicked = () => {
- this.props.setMobileScreen(MobileScreens.SET_INITIAL_LOCATION)
- }
-
- /* Called when the user selects a from/to location using the selection
- * popup (invoked in mobile mode via a long tap). Note that BaseMap already
- * takes care of updating the query in the store w/ the selected location */
-
- _locationSetFromPopup = (selection) => {
- const { intl, setLocationToCurrent } = this.props
- // If the tapped location was selected as the 'from' endpoint, set the 'to'
- // endpoint to be the current user location. (If selected as the 'to' point,
- // no action is needed since 'from' is the current location by default.)
- if (selection.type === 'from') {
- setLocationToCurrent({ locationType: 'to' }, intl)
- }
- }
-
- render () {
- return (
-
-
-
-
-
-
-
-
-
- )
- }
-}
-
-// connect to the redux store
-
-const mapStateToProps = (state, ownProps) => {
- return {}
-}
-
-const mapDispatchToProps = {
- setLocationToCurrent,
- setMobileScreen
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(
- injectIntl(MobileWelcomeScreen)
-)
diff --git a/lib/components/mobile/welcome-screen.tsx b/lib/components/mobile/welcome-screen.tsx
new file mode 100644
index 000000000..688cbb032
--- /dev/null
+++ b/lib/components/mobile/welcome-screen.tsx
@@ -0,0 +1,55 @@
+import { connect } from 'react-redux'
+import { useIntl } from 'react-intl'
+import React, { useCallback } from 'react'
+
+import * as mapActions from '../../actions/map'
+import * as uiActions from '../../actions/ui'
+import { MobileScreens } from '../../actions/ui-constants'
+import DefaultMap from '../map/default-map'
+import LocationField from '../form/connected-location-field'
+
+import MobileContainer from './container'
+import MobileNavigationBar from './navigation-bar'
+
+interface Props {
+ setMobileScreen: (screen: number) => void
+}
+
+const MobileWelcomeScreen = ({ setMobileScreen }: Props) => {
+ const intl = useIntl()
+
+ const toFieldClicked = useCallback(
+ () => setMobileScreen(MobileScreens.SET_INITIAL_LOCATION),
+ [setMobileScreen]
+ )
+
+ return (
+
+
+
+
+ )
+ }
+}
+
+// TODO: state type
+const mapStateToProps = (state: any, ownProps: Props) => {
+ const activeSearch = getActiveSearch(state)
+ const activeItineraryTimeIndex =
+ // @ts-expect-error state is not yet typed
+ activeSearch && activeSearch.activeItineraryTimeIndex
+
+ return {
+ accessibilityScoreGradationMap:
+ state.otp.config.accessibilityScore?.gradationMap,
+ activeItineraryTimeIndex,
+ arrivesAt: state.otp.currentQuery.departArrive === 'ARRIVE',
+ co2Config: state.otp.config.co2,
+ configCosts: state.otp.config.itinerary?.costs,
+ // The configured (ambient) currency is needed for rendering the cost
+ // of itineraries whether they include a fare or not, in which case
+ // we show $0.00 or its equivalent in the configured currency and selected locale.
+ currency: state.otp.config.localization?.currency || 'USD',
+ defaultFareKey: state.otp.config.itinerary?.defaultFareKey,
+ enableDot: !state.otp.config.itinerary?.disableMetroSeperatorDot,
+ // @ts-expect-error TODO: type activeSearch
+ pending: activeSearch ? Boolean(activeSearch.pending) : false,
+ showLegDurations: state.otp.config.itinerary?.showLegDurations
+ }
+}
+
+// TS TODO: correct redux types
+const mapDispatchToProps = (dispatch: any) => {
+ return {
+ setItineraryTimeIndex: (payload: number) =>
+ dispatch(narrativeActions.setActiveItineraryTime(payload)),
+ setItineraryView: (payload: any) =>
+ dispatch(uiActions.setItineraryView(payload))
+ }
+}
+
+export default injectIntl(
+ connect(mapStateToProps, mapDispatchToProps)(MetroItinerary)
+)
diff --git a/lib/components/narrative/metro/route-block.tsx b/lib/components/narrative/metro/route-block.tsx
new file mode 100644
index 000000000..dfa37408b
--- /dev/null
+++ b/lib/components/narrative/metro/route-block.tsx
@@ -0,0 +1,183 @@
+import { FormattedMessage, useIntl } from 'react-intl'
+import { Leg } from '@opentripplanner/types'
+import { RouteLongName } from '@opentripplanner/itinerary-body/lib/defaults'
+import React, { useContext } from 'react'
+import styled from 'styled-components'
+
+import { ComponentContext } from '../../../util/contexts'
+
+import DefaultRouteRenderer from './default-route-renderer'
+
+type Props = {
+ LegIcon: ({ height, leg }: { height: number; leg: Leg }) => React.ReactElement
+ footer?: React.ReactNode
+ hideLongName?: boolean
+ leg: Leg & {
+ alternateRoutes?: {
+ [id: string]: Leg
+ }
+ }
+ previousLegMode?: string
+ showDivider?: boolean
+}
+
+const Wrapper = styled.span`
+ align-items: center;
+ column-gap: 4px;
+ display: grid;
+ grid-template-columns: fit-content(100%);
+ justify-content: flex-start;
+ margin-left: -4px; /* counteract gap */
+
+ footer {
+ align-self: center;
+ font-size: 12px;
+ grid-column: 1 / span 2;
+ justify-self: center;
+ opacity: 0.7;
+ }
+`
+
+const MultiWrapper = styled.span<{ italic?: boolean; multi?: boolean }>`
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+ grid-column: 2;
+ grid-row: 1;
+
+ ${({ italic }) => italic && 'font-style: italic;'}
+ ${({ multi }) =>
+ multi
+ ? `
+ /* All Route blocks start with only right side triangulated */
+ span {
+ clip-path: polygon(0% 0, 100% 0%, 75% 100%, 0% 100%);
+ margin-right: 2px;
+ max-width: 100px;
+ padding-left: 5px;
+ padding-right: 10px;
+ }
+ span:first-of-type {
+ border-bottom-right-radius: 0!important;
+ border-top-right-radius: 0!important;
+ }
+ /* Middle route block(s), with both sides triangulated */
+ span:not(:first-of-type):not(:last-of-type) {
+ border-radius: 0;
+ clip-path: polygon(25% 0, 100% 0%, 75% 100%, 0% 100%);
+ margin-left: -10px;
+ padding-left: 11px;
+ padding-right: 11px;
+ }
+ /* Last route block, with only left side triangulated */
+ span:last-of-type {
+ border-bottom-left-radius: 0!important;
+ border-top-left-radius: 0!important;
+ clip-path: polygon(25% 0, 100% 0%, 100% 100%, 0% 100%);
+ margin-left: -10px;
+ padding-left: 10px;
+ padding-right: 5px;
+ }
+ `
+ : ''}
+`
+
+const LegIconWrapper = styled.span`
+ height: 28px;
+ max-width: 28px;
+`
+
+const MultiRouteLongName = styled.div`
+ align-items: baseline;
+ align-self: center;
+ display: flex;
+ gap: 5px;
+ grid-column: 3;
+ grid-row: 1;
+ justify-content: space-between;
+`
+
+const Divider = styled.span`
+ align-items: center;
+ display: flex;
+ line-height: 2;
+ margin: 0 -5px;
+ opacity: 0.4;
+`
+
+// eslint-disable-next-line complexity
+const RouteBlock = ({
+ footer,
+ hideLongName,
+ leg: rawLeg,
+ LegIcon,
+ previousLegMode,
+ showDivider
+}: Props): React.ReactElement | null => {
+ // @ts-expect-error React context is populated dynamically
+ const { RouteRenderer } = useContext(ComponentContext)
+ const intl = useIntl()
+
+ const Route = RouteRenderer || DefaultRouteRenderer
+ const leg = { ...rawLeg }
+
+ // Determine if the routeShortName will fit!
+ const alternateRoutesAreTooLongToDisplay =
+ leg?.alternateRoutes &&
+ Object.entries(leg.alternateRoutes || {}).every(
+ (altRoute) =>
+ // This 7 does a good job at filtering out most problematic short names,
+ // but may be revistited in the future depending on what feeds cause issues
+ altRoute[1].routeShortName && altRoute[1].routeShortName.length > 7
+ )
+
+ // If there are too many characters, disable the multiple route display
+ if (alternateRoutesAreTooLongToDisplay) {
+ leg.alternateRoutes = undefined
+ // Only overwrite the name if we're NOT rendering the long name
+ if (hideLongName) {
+ leg.routeShortName = intl.formatMessage({
+ id: 'components.MetroUI.multipleOptions'
+ })
+ }
+ }
+
+ return (
+ <>
+ {showDivider && previousLegMode && âĸ}
+
+ {leg.mode !== previousLegMode && (
+
+
+
+ )}
+ {(leg.routeShortName || leg.route || leg.routeLongName) && (
+
+
+ {Object.entries(leg?.alternateRoutes || {})?.map((altRoute) => {
+ const route = altRoute[1]
+ return
+ })}
+
+ )}
+ {!hideLongName && leg.routeLongName && (
+
+
+ {Object.entries(leg?.alternateRoutes || {})?.length > 0 && (
+
+
+
+ )}
+
+ )}
+ {/** TODO: should this be a footer tag? */}
+ {footer && }
+
+ >
+ )
+}
+
+export default RouteBlock
diff --git a/lib/components/narrative/mode-icon.js b/lib/components/narrative/mode-icon.js
deleted file mode 100644
index fd88b3054..000000000
--- a/lib/components/narrative/mode-icon.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React, { Component } from 'react'
-import PropTypes from 'prop-types'
-
-import Icon from '../util/icon'
-
-export default class ModeIcon extends Component {
- static propTypes = {
- mode: PropTypes.string
- }
- render () {
- const { defaultToText, mode } = this.props
- switch (mode) {
- case 'BICYCLE':
- return
- case 'BUS':
- return
- case 'CAR':
- return
- case 'TRAM':
- return
- case 'SUBWAY':
- return
- case 'WALK':
- return
- case 'MICROMOBILITY':
- return
- default:
- return defaultToText ? {mode} : null
- }
- }
-}
diff --git a/lib/components/narrative/narrative-itineraries-errors.tsx b/lib/components/narrative/narrative-itineraries-errors.tsx
index 87b036463..941e50754 100644
--- a/lib/components/narrative/narrative-itineraries-errors.tsx
+++ b/lib/components/narrative/narrative-itineraries-errors.tsx
@@ -1,12 +1,12 @@
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore otp-ui is not typed yet
+import { ExclamationTriangle } from '@styled-icons/fa-solid/ExclamationTriangle'
+// @ts-expect-error No typescript
import { getCompanyIcon } from '@opentripplanner/icons/lib/companies'
import { useIntl } from 'react-intl'
-import React from 'react'
+import React, { Suspense } from 'react'
import styled from 'styled-components'
import { getErrorMessage } from '../../util/state'
-import Icon from '../util/icon'
+import { StyledIconWrapper } from '../util/styledIcon'
const IssueContainer = styled.div`
border-top: 1px solid grey;
@@ -38,12 +38,20 @@ export default function NarrativeItinerariesErrors({
const intl = useIntl()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return errors.map((error: { network: any }, idx: number) => {
- let icon =
+ let icon = (
+
+
+
+ )
if (error.network) {
const CompanyIcon = getCompanyIcon(error.network)
// check if company icon exists to avoid rendering undefined
if (CompanyIcon) {
- icon =
+ icon = (
+ Loading...}>
+
+
+ )
}
}
return (
diff --git a/lib/components/narrative/narrative-itineraries-header.tsx b/lib/components/narrative/narrative-itineraries-header.tsx
index f98f7cc65..e0b251e4a 100644
--- a/lib/components/narrative/narrative-itineraries-header.tsx
+++ b/lib/components/narrative/narrative-itineraries-header.tsx
@@ -1,8 +1,13 @@
+import { ArrowLeft } from '@styled-icons/fa-solid/ArrowLeft'
+import { ExclamationTriangle } from '@styled-icons/fa-solid/ExclamationTriangle'
import { FormattedMessage, useIntl } from 'react-intl'
+import { Itinerary } from '@opentripplanner/types'
+import { SortAmountDown } from '@styled-icons/fa-solid/SortAmountDown'
+import { SortAmountUp } from '@styled-icons/fa-solid/SortAmountUp'
import React from 'react'
import styled from 'styled-components'
-import Icon from '../util/icon'
+import { IconWithText, StyledIconWrapper } from '../util/styledIcon'
import PlanFirstLastButtons from './plan-first-last-buttons'
import SaveTripButton from './save-trip-button'
@@ -16,30 +21,60 @@ const IssueButton = styled.button`
padding: 2px 4px;
`
+// h1 element for a11y purposes
+
+const InvisibleHeader = styled.h1`
+ height: 0;
+ overflow: hidden;
+ width: 0;
+`
+
export default function NarrativeItinerariesHeader({
+ customBatchUiBackground,
errors,
itineraries,
+ itinerary,
itineraryIsExpanded,
onSortChange,
onSortDirChange,
onToggleShowErrors,
onViewAllOptions,
pending,
+ popupTarget,
+ setPopupContent,
+ showHeaderText = true,
showingErrors,
sort
}: {
+ customBatchUiBackground?: boolean
errors: unknown[]
itineraries: unknown[]
+ itinerary: Itinerary
itineraryIsExpanded: boolean
onSortChange: () => void
onSortDirChange: () => void
onToggleShowErrors: () => void
onViewAllOptions: () => void
pending: boolean
+ popupTarget: string
+ setPopupContent: (url: string) => void
+ showHeaderText: boolean
showingErrors: boolean
sort: { direction: string; type: string }
}): JSX.Element {
const intl = useIntl()
+ const itinerariesFound = intl.formatMessage(
+ {
+ id: 'components.NarrativeItinerariesHeader.itinerariesFound'
+ },
+ { itineraryNum: itineraries.length }
+ )
+ const numIssues = intl.formatMessage(
+ {
+ id: 'components.NarrativeItinerariesHeader.numIssues'
+ },
+ { issueNum: errors.length }
+ )
return (
-
-
+
+
+
{itineraryIsExpanded && (
// marginLeft: auto is a way of making something "float right"
@@ -70,49 +106,88 @@ export default function NarrativeItinerariesHeader({
>
) : (
<>
-