diff --git a/.github/issue_template.md b/.github/issue_template.md index 6fd56471c..80c8c2137 100644 --- a/.github/issue_template.md +++ b/.github/issue_template.md @@ -1,4 +1,4 @@ -_**NOTE:** This issue system is intended for reporting bugs and tracking progress in software development. Although this software is licensed with an open-source license, any issue opened here may not be dealt with in a timely manner. [IBI Group](https://www.ibigroup.com/) is able to provide technical support for custom deployments of this software. Please contact [Ritesh Warade](mailto:ritesh.warade@ibigroup.com?subject=Data%20Tools%20inquiry%20via%20GitHub&body=Name:%20%0D%0AAgency/Company:%20%0D%0ABest%20date/time%20for%20a%20demo/discussion:%20%0D%0ADescription%20of%20needs:%20) if your company or organization is interested in opening a support contract with us. Please remove this note when creating the issue._ +_**NOTE:** This issue system is intended for reporting bugs and tracking progress in software development. Although this software is licensed with an open-source license, any issue opened here may not be dealt with in a timely manner. [Arcadis IBI Group](https://www.ibigroup.com/) is able to provide technical support for custom deployments of this software. Please contact [Jon Campbell](mailto:jon.campbell@ibigroup.com?subject=Data%20Tools%20inquiry%20via%20GitHub&body=Name:%20%0D%0AAgency/Company:%20%0D%0ABest%20date/time%20for%20a%20demo/discussion:%20%0D%0ADescription%20of%20needs:%20) if your company or organization is interested in opening a support contract with us. Please remove this note when creating the issue._ ## Observed behavior (please include a screenshot if possible) diff --git a/.gitignore b/.gitignore index 132e7ba2d..97bf6c341 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ env.yml env.yml-original .env !configurations/test/env.yml +!docker/server/env.yml scripts/*client.json *.pem diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..e138fa82d --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,14 @@ +# Read the Docs configuration file for MkDocs projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +mkdocs: + configuration: mkdocs.yml diff --git a/README.md b/README.md index 58a9cf9d2..c878c1bc9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ # datatools-ui -The core application for IBI Group's transit Data Tools suite. This application provides GTFS editing, management, validation, and deployment to OpenTripPlanner. +[![Join the chat at https://matrix.to/#/#transit-data-tools:gitter.im](https://badges.gitter.im/repo.png)](https://matrix.to/#/#transit-data-tools:gitter.im) + +The core application for IBI Group's TRANSIT-Data-Tools suite. This application provides GTFS editing, management, validation, and deployment to OpenTripPlanner. + +## Quick Start + +A pre-configured datatools instance can be lauched via Docker by running + +```bash +cd docker +cp ../configurations/default/env.yml.tmp ../configurations/default/env.yml +docker-compose up +``` + +from the datatools-ui directory. Datatools will then be running on port `9966`. + +Deployment functionality will not work, and persistence may only work in certain cases (look into Docker volumes for more info). ## Configuration @@ -12,6 +28,10 @@ View the [latest release documentation](http://data-tools-docs.ibi-transit.com/e Note: `dev` branch docs (which refer to the default `branch` and are more up-to-date and accurate for most users) can be found [here](http://data-tools-docs.ibi-transit.com/en/dev/). +## Getting in touch + +We have a Gitter [space](https://matrix.to/#/#transit-data-tools:gitter.im) for the full TRANSIT-Data-Tools project where you can post questions and comments. + ## Shoutouts 🙏 BrowserStack Logo diff --git a/__tests__/e2e/server/Dockerfile b/__tests__/e2e/server/Dockerfile index a1289e2f4..68b7af3e6 100644 --- a/__tests__/e2e/server/Dockerfile +++ b/__tests__/e2e/server/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 -FROM maven:3.8.6-openjdk-8 +FROM maven:3.8.7-openjdk-18 + WORKDIR /datatools ARG E2E_AUTH0_USERNAME @@ -32,6 +33,7 @@ ARG AWS_SECRET_ACCESS_KEY # Grab latest dev build of Datatools Server RUN git clone https://github.com/ibi-group/datatools-server.git +RUN microdnf install wget WORKDIR /datatools/datatools-server RUN mvn package -DskipTests diff --git a/__tests__/test-utils/mock-data/manager.js b/__tests__/test-utils/mock-data/manager.js index eeb5674e7..6aba37825 100644 --- a/__tests__/test-utils/mock-data/manager.js +++ b/__tests__/test-utils/mock-data/manager.js @@ -71,6 +71,7 @@ export function makeMockDeployment ( peliasCsvFiles: [], peliasResetDb: null, peliasUpdate: null, + peliasSynonymsBase64: null, pinnedfeedVersionIds: [], projectBounds: {east: 0, west: 0, north: 0, south: 0}, projectId: project.id, @@ -109,8 +110,8 @@ export const mockProject = { pinnedDeploymentId: null, peliasWebhookUrl: null, routerConfig: { - carDropoffTime: null, - numItineraries: null, + driveDistanceReluctance: null, + itineraryFilters: {nonTransitGeneralizedCostLimit: null}, requestLogFile: null, stairsReluctance: null, updaters: null, @@ -340,6 +341,7 @@ export const mockFeedVersion = { feedVersionId: 'mock-feed-version-id', loadFailureReason: null, loadStatus: 'SUCCESS', + mobilityDataResult: {}, routeCount: 10, startDate: '20180801', stopCount: 237, diff --git a/configurations/default/env.yml.tmp b/configurations/default/env.yml.tmp index be5572a42..bd48088ad 100644 --- a/configurations/default/env.yml.tmp +++ b/configurations/default/env.yml.tmp @@ -2,6 +2,7 @@ AUTH0_CLIENT_ID: your-auth0-client-id AUTH0_CONNECTION_NAME: your-auth0-connection-name AUTH0_DOMAIN: your-auth0-domain BUGSNAG_KEY: optional-bugsnag-key +MAP_BASE_URL: optional-map-tile-url MAPBOX_ACCESS_TOKEN: your-mapbox-access-token MAPBOX_MAP_ID: mapbox/outdoors-v11 MAPBOX_ATTRIBUTION: © Mapbox © OpenStreetMap Improve this map @@ -22,3 +23,4 @@ GRAPH_HOPPER_KEY: your-graph-hopper-key # - 83 GOOGLE_ANALYTICS_TRACKING_ID: optional-ga-key # GRAPH_HOPPER_POINT_LIMIT: 10 # Defaults to 30 +DISABLE_AUTH: true \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..9055475a6 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,71 @@ +version: "3.8" + +x-common-variables: &common-variables + - BUGSNAG_KEY=${BUGSNAG_KEY} + - S3_BUCKET=${S3_BUCKET} + - LOGS_S3_BUCKET=${LOGS_S3_BUCKET} + - MS_TEAMS_WEBHOOK_URL=${MS_TEAMS_WEBHOOK_URL} + - MAPBOX_ACCESS_TOKEN=${MAPBOX_ACCESS_TOKEN} + - GITHUB_SHA=${GITHUB_SHA} + - GITHUB_REF_SLUG=${GITHUB_REF_SLUG} + - TRANSITFEEDS_KEY=${TRANSITFEEDS_KEY} + - GITHUB_REPOSITORY=${GITHUB_REPOSITORY} + - GITHUB_WORKSPACE=${GITHUB_WORKSPACE} + - GITHUB_RUN_ID=${GITHUB_RUN_ID} + - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} + - AUTH0_PUBLIC_KEY=${AUTH0_PUBLIC_KEY} + - AUTH0_CONNECTION_NAME=${AUTH0_CONNECTION_NAME} + - AUTH0_DOMAIN=${AUTH0_DOMAIN} + - AUTH0_API_CLIENT=${AUTH0_API_CLIENT} + - AUTH0_API_SECRET=${AUTH0_API_SECRET} + - OSM_VEX=${OSM_VEX} + - SPARKPOST_KEY=${SPARKPOST_KEY} + - SPARKPOST_EMAIL=${SPARKPOST_EMAIL} + - GTFS_DATABASE_URL=jdbc:postgresql://postgres/dmtest + - GTFS_DATABASE_USER=root + - GTFS_DATABASE_PASSWORD=e2e + - MONGO_DB_NAME=data_manager + - MONGO_HOST=mongo:27017 + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_REGION=${AWS_REGION} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - DISABLE_AUTH=true + +services: + datatools-server: + image: ghcr.io/ibi-group/datatools-server:dev + restart: always + environment: *common-variables + volumes: + - type: bind + source: ./server/ + target: /config + ports: + - "4000:4000" + datatools-ui: + build: + context: ../ + dockerfile: ./docker/ui/Dockerfile + args: *common-variables + restart: always + environment: *common-variables + ports: + - "9966:9966" + mongo: + image: mongo + restart: always + volumes: + - dt-mongo:/data/db + postgres: + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_USER: root + POSTGRES_PASS: e2e + POSTGRES_DB: dmtest + image: postgres + restart: always + volumes: + - dt-postgres:/var/lib/postgresql/data +volumes: + dt-postgres: + dt-mongo: diff --git a/docker/server/env.yml b/docker/server/env.yml new file mode 100644 index 000000000..9fe4c3cbb --- /dev/null +++ b/docker/server/env.yml @@ -0,0 +1,5 @@ +DISABLE_AUTH: TRUE +GTFS_DATABASE_URL: jdbc:postgresql://postgres/dmtest +MONGO_DB_NAME: data_manager +MONGO_HOST: mongo +AUTH0_CLIENT_ID: disable_auth \ No newline at end of file diff --git a/docker/server/server.yml b/docker/server/server.yml new file mode 100644 index 000000000..f212cbe7e --- /dev/null +++ b/docker/server/server.yml @@ -0,0 +1,67 @@ +application: + title: Data Tools + logo: https://d2tyb7byn1fef9.cloudfront.net/ibi_group-128x128.png + logo_large: https://d2tyb7byn1fef9.cloudfront.net/ibi_group_black-512x512.png + client_assets_url: https://example.com + shortcut_icon_url: https://d2tyb7byn1fef9.cloudfront.net/ibi-logo-original%402x.png + public_url: http://localhost:9966 + notifications_enabled: false + docs_url: http://conveyal-data-tools.readthedocs.org + support_email: support@ibigroup.com + port: 4000 + data: + gtfs: /tmp + use_s3_storage: false + s3_region: us-east-1 + gtfs_s3_bucket: bucket-name +modules: + enterprise: + enabled: false + editor: + enabled: true + deployment: + enabled: true + ec2: + enabled: false + default_ami: ami-your-ami-id + tag_key: a-tag-key-to-add-to-all-instances + tag_value: a-tag-value-to-add-to-all-instances + # Note: using a cloudfront URL for these download URLs will greatly + # increase download/deploy speed. + otp_download_url: https://optional-otp-repo.com + user_admin: + enabled: false + gtfsapi: + enabled: true + load_on_fetch: false + # use_extension: mtc + # update_frequency: 30 # in seconds + manager: + normalizeFieldTransformation: + # Enter capitalization exceptions (e.g. acronyms), in the desired case, and separated by commas. + defaultCapitalizationExceptions: + - ACE + - BART + # Enter substitutions (e.g. substitute '@' with 'at'), one dashed entry for each substitution, with: + # - pattern: the regex string pattern that will be replaced, + # - replacement: the replacement string for that pattern, + # - normalizeSpace: if true, the resulting field value will include one space before and after the replacement string. + # Note: if the replacement must be blank, then normalizeSpace should be set to false + # and whitespace management should be handled in pattern instead. + # Substitutions are executed in order they appear in the list. + defaultSubstitutions: + - description: "Replace '@' with 'at', and normalize space." + pattern: "@" + replacement: at + normalizeSpace: true + - description: "Replace '+' (\\+ in regex) and '&' with 'and', and normalize space." + pattern: "[\\+&]" + replacement: and + normalizeSpace: true +extensions: + transitland: + enabled: true + api: https://transit.land/api/v1/feeds + transitfeeds: + enabled: true + api: http://api.transitfeeds.com/v1/getFeeds \ No newline at end of file diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile new file mode 100644 index 000000000..a3f1bc811 --- /dev/null +++ b/docker/ui/Dockerfile @@ -0,0 +1,16 @@ +FROM node:14 +WORKDIR /datatools-build + +ARG BUGSNAG_KEY + +RUN cd /datatools-build +COPY package.json yarn.lock patches /datatools-build/ +RUN yarn +COPY . /datatools-build/ +COPY configurations/default /datatools-config/ + + +# Copy the tmp file to the env.yml if no env.yml is present +RUN cp -R -u -p /datatools-config/env.yml.tmp /datatools-config/env.yml + +CMD yarn run mastarm build --env dev --serve --proxy http://datatools-server:4000/api # \ No newline at end of file diff --git a/docs/dev/deployment.md b/docs/dev/deployment.md index 88f900586..7066db16c 100644 --- a/docs/dev/deployment.md +++ b/docs/dev/deployment.md @@ -162,29 +162,29 @@ To allow for the creation, deletion and editing of users you must generate a tok - **users_app_metadata**: - read, update, create and delete -#### Auth0 Rule Configuration: making app_metadata and user_metadata visible via token (only required for "new" Auth0 accounts/tenants) -When working with OIDC-conformant clients/APIs, which is mandatory for new Auth0 tenants, it's essential to configure a custom Auth0 rule for adding app_metadata and user_metadata to the user's token. Please note that this isn't the default behavior for older "legacy" Auth0 accounts. To set up this rule, follow these steps: +#### Auth0 Post-Login Action Configuration: making `app_metadata` and `user_metadata` visible via token -Navigate to Rules > Create Rule. -Create an empty rule and insert the following code snippet: +If using OIDC-conformant clients/APIs (which appears to be mandatory for new Auth0 tenants), you must set up a custom Auth0 action to add `app_metadata` and `user_metadata` to the user's id token (Note: this is not the default for older, "legacy" Auth0 accounts). -``` -function (user, context, callback) { - if (context.clientID === 'YOUR_CLIENT_ID') { - var namespace = 'http://datatools/'; - if (context.idToken && user.user_metadata) { - context.idToken[namespace + 'user_metadata'] = user.user_metadata; - } - if (context.idToken && user.app_metadata) { - context.idToken[namespace + 'app_metadata'] = user.app_metadata; - } +To set up the action, go to Actions > Flows > Login, then under Add action > Custom, click Create Action. Fill in the action name and pick a recommended runtime, and click Create. Modify the function `onExecutePostLogin` as follows, then click Save Draft: + +```js +exports.onExecutePostLogin = async (event, api) => { + if (event.authorization) { + const namespace = 'http://datatools'; + api.idToken.setCustomClaim(`${namespace}/user_metadata`, event.user.user_metadata); + api.idToken.setCustomClaim(`${namespace}/app_metadata`, event.user.app_metadata); } - callback(null, user, context); -} +}; ``` If you want the rule to apply only to specific clients, you can retain the conditional block that checks the `context.clientID` value. Otherwise, you can remove this conditional block if it's not needed. This rule will ensure that app_metadata and user_metadata are included in the user's token, as required for OIDC-conformant clients/APIs in new Auth0 tenants. +You can test the action with mock token data using the Test tab. Once ready, click Deploy, then click Back to Flow. +In the diagram, drag the action between the Start and Complete steps, then click Apply. +You can test that the action is correctly executed by logging-in to datatools with an admin user +and checking that the Admin functionality is available. + ## Building and Running the Application Install the Javascript dependencies for `datatools-ui` using yarn: diff --git a/i18n/english.yml b/i18n/english.yml index 382772209..bed509560 100644 --- a/i18n/english.yml +++ b/i18n/english.yml @@ -7,6 +7,10 @@ components: errorWithStatus: Error (%status%) making %method% request to %url% networkError: Network error (%status%)!\n\n(%method% request on %url%) noFile: No file to upload! + AddCustomFile: + addCustomCsvData: Add the custom CSV data. + customFileName: Custom file name + saveCsvAndFileName: Save CSV and file name AdminPage: applicationLogs: Application logs backToDashboard: Back to dashboard @@ -90,6 +94,8 @@ components: missingNameAlert: Must give snapshot a valid name! ok: OK title: Create a new snapshot + CustomCSVForm: + numLines: "%numLines% lines." DatatoolsNavbar: account: My Account alerts: Alerts @@ -144,13 +150,16 @@ components: routerConfig: brandingUrlRoot: Branding URL Root carDropoffTime: Car Dropoff Time + driveDistanceReluctance: Drive Distance Reluctance numItineraries: '# of itineraries' requestLogFile: Request log file stairsReluctance: Stairs Reluctance title: Router Config + itineraryFilters: + nonTransitGeneralizedCostLimit: Non-Transit Generalized Cost Limit updaters: $index: - defaultAgencyId: Default agency ID + feedId: Feed ID frequencySec: Frequency (in seconds) sourceType: Source type type: Type @@ -316,6 +325,15 @@ components: createStop: Right-click a location on map to create a new stop editSchedules: Edit schedules name: Name + ExceptionDate: + addRange: Add range + dateRemoved: ⓘ Date has been removed. Date entered is already included in an existing range or single date! + selectDate: Select date + ExceptionDateRange: + deleteEndDate: Delete end date + deleteRange: Delete range + ExceptionValidationErrorsList: + andOtherErrors: ...and %errors% other errors FeedFetchFrequency: DAYS: days fetchFeedEvery: Fetch feed every @@ -450,6 +468,9 @@ components: properties: properties warning: Warning! FeedTransformationDescriptions: + AddCustomFileTransformation: + label: Add custom file in GTFS. + name: Add custom file transformation general: fileDefined: below text filePlaceholder: '[choose file]' @@ -464,6 +485,10 @@ components: filePlaceholder: Choose file/table to normalize label: Normalize field name: Normalize field transformation + PreserveCustomFieldsTransformation: + filePlaceholder: Choose the file/table with custom field + label: Preserve fields in %tablePlaceholder% from %filePlaceholder% + name: Preserve custom fields transformation ReplaceFileFromStringTransformation: filePlaceholder: Choose the file/table to replace label: Replace %tablePlaceholder% from %filePlaceholder% @@ -472,6 +497,11 @@ components: filePlaceholder: Choose the file/table to replace label: Replace %tablePlaceholder% from %versionPlaceholder% name: Replace file from version transformation + FeedTransformationErrors: + csvNameContainsTxt: Custom CSV file name cannot end with ".txt". + csvMissingName: Custom CSV must have a name. + undefinedCSVData: CSV data must be defined. + undefinedTable: Table must be defined. FeedVersionNavigator: confirmDelete: Are you sure you want to delete this version? This cannot be undone. confirmLoad: 'This will override all active GTFS Editor data for this Feed Source with the data from this version. If there is unsaved work in the Editor you want to keep, you must snapshot the current Editor data first. Are you sure you want to continue?' @@ -533,13 +563,16 @@ components: routerConfig: brandingUrlRoot: Branding URL Root carDropoffTime: Car Dropoff Time + driveDistanceReluctance: Drive Distance Reluctance numItineraries: '# of itineraries' requestLogFile: Request log file stairsReluctance: Stairs Reluctance title: Router Config + itineraryFilters: + nonTransitGeneralizedCostLimit: Non-Transit Generalized Cost Limit updaters: $index: - defaultAgencyId: Default agency ID + feedId: Feed ID frequencySec: Frequency (in seconds) sourceType: Source type type: Type @@ -619,13 +652,16 @@ components: routerConfig: brandingUrlRoot: Branding URL Root carDropoffTime: Car Dropoff Time + driveDistanceReluctance: Drive Distance Reluctance numItineraries: '# of itineraries' requestLogFile: Request log file stairsReluctance: Stairs Reluctance title: Router Config + itineraryFilters: + nonTransitGeneralizedCostLimit: Non-Transit Generalized Cost Limit updaters: $index: - defaultAgencyId: Default agency ID + feedId: Feed ID frequencySec: Frequency (in seconds) sourceType: Source type type: Type @@ -849,6 +885,9 @@ components: edit-alert: Edit GTFS-RT Alerts edit-gtfs: Edit GTFS Feeds manage-feed: Manage Feed Configuration + PreserveCustomFields: + addCsvWithCustomFields: "Add the CSV data with custom fields to preserve in the final output." + saveCsv: Save CSV ProjectAccessSettings: admin: Admin cannotFetchFeeds: Cannot fetch feeds @@ -891,13 +930,16 @@ components: routerConfig: brandingUrlRoot: Branding URL Root carDropoffTime: Car Dropoff Time + driveDistanceReluctance: Drive Distance Reluctance numItineraries: '# of itineraries' requestLogFile: Request log file stairsReluctance: Stairs Reluctance title: Router Config + itineraryFilters: + nonTransitGeneralizedCostLimit: Non-Transit Generalized Cost Limit updaters: $index: - defaultAgencyId: Default agency ID + feedId: Feed ID frequencySec: Frequency (in seconds) sourceType: Source type type: Type @@ -977,7 +1019,7 @@ components: mergeFeeds: Merge all noProjectFound: No project found for note: "Note:" - publicViewed: Public feeds page can be viewed + publicViewed: "Public feeds page can be viewed " returnToProjects: Return to list of projects settings: Settings ProjectFeedListToolbar: @@ -1101,6 +1143,9 @@ components: viewDashboard: View dashboard RegionSearch: placeholder: Search for regions or agencies + ReplaceFileFromString: + addCsvData: "Add the CSV data to add to/replace in the incoming GTFS:" + saveCsv: Save CSV ResultTable: affectedIds: Affected ID(s) description: Description @@ -1132,6 +1177,7 @@ components: noEC2Instances: No EC2 instances associated with server. ShowAllRoutesOnMapFilter: fetching: Fetching + noShapes: The feed does not have any route shapes created yet. showAllRoutesOnMap: Show all routes tooManyShapeRecords: large shapes.txt may impact performance Sidebar: @@ -1264,7 +1310,19 @@ components: title: Timetable editor keyboard shortcuts TimezoneSelect: placeholder: Select timezone... + TransformationsViewer: + columnsAdded: "Custom columns added: %columns%" + noTransformationApplied: No transformations applied. + rowsAdded: "Rows added: %rows%" + rowsDeleted: "Rows deleted: %rows%" + rowsUpdated: "Rows updated: %rows%" + tableModified: Table Modified + tableAdded: Table Added + tableReplaced: Table Replaced + tableDeleted: Table Deleted + transformationsTitle: Transformations TripSeriesModal: + automaticallyUpdateTripIds: Automatically update Trip IDs for trips created in series? close: Close createTripSeriesBody: Enter the start and end time for the trip series (24 hour time) and headway between trips. Click generate to create the series of trips. createTripSeriesQuestion: Create a series of trips @@ -1272,6 +1330,10 @@ components: endTime: "End Time:" generateTrips: Generate Trips headway: "Headway:" + incrementAmountPlaceholder: Increment amount + incrementBy: inc. by + incrementStartPlaceholder: Increment start (default 0) + prefixPlaceholder: Trip ID prefix (optional) startTime: "Start Time:" UserAccount: account: @@ -1303,6 +1365,9 @@ components: myAccount: My account logout: Log out UserHomePage: + authDisabledInfo: >- + You are running %appTitle% without user authentication enabled. + Features such as user management, account management and feed activity watching are unavailable. createFirst: Create my first project help: content: 'A project is used to group GTFS feeds. For example, the feeds in a project may be in the same region or they may collectively define a planning scenario.' @@ -1358,6 +1423,24 @@ components: custom: Custom noAccess: No Access save: Save + Validation: + agencyRequired: Field must be populated for feeds with more than one agency. + dateServiceIdCombinationDuplicate: Date (%exceptionDate%) and Service ID (%serviceId%) combination cannot appear more than once for all exceptions. + idMustBeUnique: Identifier must be unique. + idRequired: Identifier is required if more than one agency exists. + invalidEmail: Field must contain valid email address. + invalidLatitude: Field must be valid latitude. + invalidLongitude: Field must be valid longitude. + invalidRouteType: Field must be a valid route type. + invalidUrl: Field must contain valid URL. + latLonRequired: Latitude and Longitude are required for your current location type. + mustBePositiveInteger: Field must be a positive integer. + mustBePositiveNumber: Field must be a positive number. + mustBeValidNumber: Field must be a valid number. + nameAlreadyUsed: "%name% is already used in another exception," + serviceRequired: Calendar must have service for at least one day. + stopNameRequired: Stop name is required for stop, station, and entrance location types. + requiredFieldEmpty: Required field must not be empty. VersionButtonToolbar: confirmDelete: Are you sure you want to delete this version? This cannot be undone. confirmLoad: 'This will override all active GTFS Editor data for this Feed Source with the data from this version. If there is unsaved work in the Editor you want to keep, you must snapshot the current Editor data first. Are you sure you want to continue?' diff --git a/i18n/german.yml b/i18n/german.yml index e281916d9..c6cadaf2d 100644 --- a/i18n/german.yml +++ b/i18n/german.yml @@ -7,6 +7,10 @@ components: errorWithStatus: Fehler (%status%) beim %method% Aufruf von %url% networkError: Netzwerk-Fehler (%status%)!\n\n(%method% Aufruf von %url%) noFile: Keine Datei zum Hochladen! + AddCustomFile: + addCustomCsvData: Add the custom CSV data. + customFileName: Custom file name + saveCsvAndFileName: Save CSV and file name AdminPage: applicationLogs: Anwendungs-Logs backToDashboard: ZurĂŒck zum Dashboard @@ -332,6 +336,16 @@ components: auf eine Örtlickeit in der Karte editSchedules: Abfahrzeiten erstellen name: Name + ExceptionDate: + addRange: Add range + dateRemoved: ⓘ Date has been removed. Date entered is already included in an existing range or single date! + selectDate: Select date + ExceptionDateRange: + andOtherErrors: ...and %errors% other errors + deleteEndDate: Delete end date + deleteRange: Delete range + ExceptionValidationErrorsList: + andOtherErrors: ...and %errors% other errors FeedActionsDropdown: delete: Löschen deleteFeedSource: Feed-Quelle löschen? @@ -492,6 +506,13 @@ components: tablePlaceholder: '[Tabelle auswĂ€hlen]' version: Version versionPlaceholder: '[Version auswĂ€hlen]' + AddCustomFileTransformation: + label: Add custom file in GTFS. + name: Add custom file transformation + PreserveCustomFieldsTransformation: + filePlaceholder: Choose the file/table with custom field + label: Preserve fields in %tablePlaceholder% from %filePlaceholder% + name: Preserve custom fields transformation FeedVersionNavigator: confirmDelete: Sind Sie sicher, dass sie diese Version endgĂŒltig löschen möchten? confirmLoad: Dies wird alle aktiven GTFS Editor Daten dieser Feed-Quelle mit den @@ -857,6 +878,9 @@ components: edit-alert: GTFS-RT Alerts bearbeiten edit-gtfs: GTFS Feeds bearbeiten manage-feed: Feed-Konfiguration verwalten + PreserveCustomFields: + addCsvWithCustomFields: "Add the CSV data with custom fields to preserve in the final output." + saveCsv: Save CSV ProjectAccessSettings: admin: Admin cannotFetchFeeds: Feeds nicht abrufbar @@ -1080,7 +1104,7 @@ components: mergeFeeds: Alle zusammefĂŒhren noProjectFound: Keine Projekte gefunden fĂŒr note: 'Hinweis:' - publicViewed: Öffentliche Feed-Seite ist sichtbar + publicViewed: "Öffentliche Feed-Seite ist sichtbar " returnToProjects: ZurĂŒck zur Projektliste settings: Einstellungen ProjectsList: @@ -1116,6 +1140,9 @@ components: viewDashboard: Dashboard ansehen RegionSearch: placeholder: Suche nach Regionen oder Unternehmen + ReplaceFileFromString: + addCsvData: "Add the CSV data to add to/replace in the incoming GTFS:" + saveCsv: Save CSV ResultTable: affectedIds: Betroffene ID(s) description: Beschreibung @@ -1160,6 +1187,7 @@ components: useELB: Elastic Load Balancer (ELB) verwenden? ShowAllRoutesOnMapFilter: fetching: Abruf lĂ€uft... + noShapes: FĂŒr dieses Feed wurden noch keine Routenformen erstellt. showAllRoutesOnMap: Zeige alle Routen tooManyShapeRecords: große shapes.txt können Performance beeintrĂ€chtigen Sidebar: @@ -1304,6 +1332,17 @@ components: title: Abfahrtszeiten-Editor Tastatur-Shortcuts TimezoneSelect: placeholder: Zeitzone auswĂ€hlen... + TransformationsViewer: + columnsAdded: "Custom columns added: %columns%" + noTransformationApplied: No transformations applied. + rowsAdded: "Rows added: %rows%" + rowsDeleted: "Rows deleted: %rows%" + rowsUpdated: "Rows updated: %rows%" + tableModified: Table Modified + tableAdded: Table Added + tableReplaced: Table Replaced + tableDeleted: TableDeleted + transformationsTitle: Transformations UserAccount: account: title: Konto @@ -1334,6 +1373,9 @@ components: logout: Abmelden myAccount: Mein Konto UserHomePage: + authDisabledInfo: >- + You are running %appTitle% without user authentication enabled. + Features such as user management, account management and feed activity watching are unavailable. createFirst: Erstelle mein erstes Projekt help: content: Ein Projekt dient dazu, GTFS-Feeds zu gruppieren. Zum Beispiel können @@ -1393,6 +1435,24 @@ components: custom: Benutzerdefiniert noAccess: Kein Zugriff save: Speichern + Validation: + agencyRequired: Field must be populated for feeds with more than one agency. + dateServiceIdCombinationDuplicate: Date (%exceptionDate%) and Service ID (%serviceId%) combination cannot appear more than once for all exceptions. + idMustBeUnique: Identifier must be unique. + idRequired: Identifier is required if more than one agency exists. + invalidEmail: Field must contain valid email address. + invalidLatitude: Field must be valid latitude. + invalidLongitude: Field must be valid longitude. + invalidRouteType: Field must be a valid route type. + invalidUrl: Field must contain valid URL. + latLonRequired: Latitude and Longitude are required for your current location type. + mustBePositiveInteger: Field must be a positive integer. + mustBePositiveNumber: Field must be a positive number. + mustBeValidNumber: Field must be a valid number. + nameAlreadyUsed: "%name% is already used in another exception." + serviceRequired: Calendar must have service for at least one day. + stopNameRequired: Stop name is required for stop, station, and entrance location types. + requiredFieldEmpty: Required field must not be empty. VersionButtonToolbar: confirmDelete: Sind Sie sicher, dass sie diese Version endgĂŒltig löschen möchten? confirmLoad: Dies wird alle aktiven GTFS Editor Daten dieser Feed-Quelle mit den @@ -1429,3 +1489,49 @@ components: WrapComponentInAuthStrategy: adminTestFailed: Sie haben versucht, eine eingeschrĂ€nkt sichtbare Seite aufzurufen, verfĂŒgen jedoch nicht ĂŒber die erforderlichen Rechte. + CustomCSVForm: + numLines: "%numLines% lines." + FeedTransformationErrors: + csvMissingName: Custom CSV must have a name. + csvNameContainsTxt: Custom CSV name cannot contain .txt + undefinedCSVData: CSV data must be defined. + undefinedTable: Table must be defined + PatternStopCard: + PatternStopContents: + defaultDwellTime: Default dwell time + defaultTravelTime: Default travel time + firstStopTravelTime: Travel time for first stop must be zero + stopHeadsignPlaceholder: Outbound + stopHeadsignText: Stop headsign + stopHeadsignTitle: Headsign that overrides trip headsign between stops. + timepoint: Timepoint? + travelTimeHelp: Define the default time it takes to travel to this stop from + the previous stop. + PickupDropOffSelect: + available: Available (0) + continuousDropOffTitle: Indicates whether a rider can alight from the transit + vehicle at any point along the vehicle's travel path. + continuousPickupTitle: Indicates whether a rider can board the transit vehicle + anywhere along the vehicle's travel path. + continuousServiceDefault: (Inherit from route) + dropOffTitle: Define the dropff method/availability at this stop. + mustCoordinate: Must coordinate with driver to arrange (3) + mustPhoneAgency: Must phone agency to arrange (2) + notAvailable: Not available (1) + pickupDropOffDefault: (Default - Available) + pickupTitle: Define the pickup method/availability at this stop. + TripSeriesModal: + automaticallyUpdateTripIds: Automatically update Trip IDs for trips created in series? + close: Close + createTripSeriesBody: Enter the start and end time for the trip series (24 hour + time) and headway between trips. Click generate to create the series of trips. + createTripSeriesQuestion: Create a series of trips + disabledTooltip: There is an issue with the input data + endTime: "End Time:" + generateTrips: Generate Trips + headway: "Headway:" + incrementAmountPlaceholder: Increment amount + incrementBy: inc. by + incrementStartPlaceholder: Increment start (default 0) + prefixPlaceholder: Trip ID prefix (optional) + startTime: "Start Time:" diff --git a/i18n/polish.yml b/i18n/polish.yml index a248b2a33..23e84abcb 100644 --- a/i18n/polish.yml +++ b/i18n/polish.yml @@ -7,6 +7,10 @@ components: errorWithStatus: Error (%status%) making %method% request to %url% networkError: Network error (%status%)!\n\n(%method% request on %url%) noFile: No file to upload! + AddCustomFile: + addCustomCsvData: Add the custom CSV data. + customFileName: Custom file name + saveCsvAndFileName: Save CSV and file name AdminPage: applicationLogs: Application logs backToDashboard: Back to dashboard @@ -89,6 +93,8 @@ components: label: Password placeholder: Enter password for new user title: Create User + CustomCSVForm: + numLines: "%numLines% lines." DatatoolsNavbar: account: Moje konto alerts: Alerty @@ -325,6 +331,16 @@ components: createStop: Right-click a location on map to create a new stop editSchedules: Edit schedules name: Name + ExceptionDate: + addRange: Add range + dateRemoved: ⓘ Date has been removed. Date entered is already included in an existing range or single date! + selectDate: Select date + ExceptionDateRange: + andOtherErrors: ...and %errors% other errors + deleteEndDate: Delete end date + deleteRange: Delete range + ExceptionValidationErrorsList: + andOtherErrors: ...and %errors% other errors FeedActionsDropdown: delete: Delete deleteFeedSource: Delete Feed Source? @@ -483,6 +499,13 @@ components: tablePlaceholder: '[choose table]' version: version versionPlaceholder: '[choose version]' + AddCustomFileTransformation: + label: Add custom file in GTFS. + name: Add custom file transformation + PreserveCustomFieldsTransformation: + filePlaceholder: Choose the file/table with custom field + label: Preserve fields in %tablePlaceholder% from %filePlaceholder% + name: Preserve custom fields transformation FeedVersionNavigator: confirmDelete: Are you sure you want to delete this version? This cannot be undone. confirmLoad: This will override all active GTFS Editor data for this Feed Source @@ -843,6 +866,9 @@ components: edit-alert: Edit GTFS-RT Alerts edit-gtfs: Edit GTFS Feeds manage-feed: Manage Feed Configuration + PreserveCustomFields: + addCsvWithCustomFields: "Add the CSV data with custom fields to preserve in the final output." + saveCsv: Save CSV ProjectAccessSettings: admin: Admin cannotFetchFeeds: Cannot fetch feeds @@ -1063,7 +1089,7 @@ components: mergeFeeds: Merge all noProjectFound: No project found for note: 'Note:' - publicViewed: Public feeds page can be viewed + publicViewed: "Public feeds page can be viewed " returnToProjects: Return to list of projects settings: Settings ProjectsList: @@ -1099,6 +1125,9 @@ components: viewDashboard: View dashboard RegionSearch: placeholder: Search for regions or agencies + ReplaceFileFromString: + addCsvData: "Add the CSV data to add to/replace in the incoming GTFS:" + saveCsv: Save CSV ResultTable: affectedIds: Affected ID(s) description: Description @@ -1137,6 +1166,7 @@ components: useELB: Use Elastic Load Balancer (ELB)? ShowAllRoutesOnMapFilter: fetching: Fetching + noShapes: Karma nie ma jeszcze utworzonych ĆŒadnych ksztaƂtĂłw tras. showAllRoutesOnMap: Show all routes tooManyShapeRecords: large shapes.txt may impact performance Sidebar: @@ -1224,6 +1254,7 @@ components: UPLOADING_FEED: Uploading feed... UPLOADING_GTFSPLUS_FEED: Saving GTFS+ data... VALIDATING_GTFSPLUS_FEED: Updating GTFS+ validation... + RUNNING_FETCH_FEED: Fetching feed... StatusModal: close: Close login: Log in @@ -1277,6 +1308,17 @@ components: title: Timetable editor keyboard shortcuts TimezoneSelect: placeholder: Select timezone... + TransformationsViewer: + columnsAdded: "Custom columns added: %columns%" + noTransformationApplied: No transformations applied. + rowsAdded: "Rows added: %rows%" + rowsDeleted: "Rows deleted: %rows%" + rowsUpdated: "Rows updated: %rows%" + tableModified: Table Modified + tableAdded: Table Added + tableReplaced: Table Replaced + tableDeleted: TableDeleted + transformationsTitle: Transformations UserAccount: account: title: Account @@ -1307,6 +1349,9 @@ components: logout: Log out myAccount: My account UserHomePage: + authDisabledInfo: >- + You are running %appTitle% without user authentication enabled. + Features such as user management, account management and feed activity watching are unavailable. createFirst: Create my first project help: content: A project is used to group GTFS feeds. For example, the feeds in a @@ -1365,6 +1410,24 @@ components: custom: Custom noAccess: No Access save: Save + Validation: + agencyRequired: Field must be populated for feeds with more than one agency. + dateServiceIdCombinationDuplicate: Date (%exceptionDate%) and Service ID (%serviceId%) combination cannot appear more than once for all exceptions. + idMustBeUnique: Identifier must be unique. + idRequired: Identifier is required if more than one agency exists. + invalidEmail: Field must contain valid email address. + invalidLatitude: Field must be valid latitude. + invalidLongitude: Field must be valid longitude. + invalidRouteType: Field must be a valid route type. + invalidUrl: Field must contain valid URL. + latLonRequired: Latitude and Longitude are required for your current location type. + mustBePositiveInteger: Field must be a positive integer. + mustBePositiveNumber: Field must be a positive number. + mustBeValidNumber: Field must be a valid number. + nameAlreadyUsed: "%name% is already used in another exception." + serviceRequired: Calendar must have service for at least one day. + stopNameRequired: Stop name is required for stop, station, and entrance location types. + requiredFieldEmpty: Required field must not be empty. VersionButtonToolbar: confirmDelete: Are you sure you want to delete this version? This cannot be undone. confirmLoad: This will override all active GTFS Editor data for this Feed Source @@ -1399,3 +1462,47 @@ components: watch: Watch WrapComponentInAuthStrategy: adminTestFailed: You have attempted to view a restricted page without proper credentials + FeedTransformationErrors: + csvMissingName: Custom CSV must have a name. + csvNameContainsTxt: Custom CSV name cannot contain .txt + undefinedCSVData: CSV data must be defined. + undefinedTable: Table must be defined + PatternStopCard: + PatternStopContents: + defaultDwellTime: Default dwell time + defaultTravelTime: Default travel time + firstStopTravelTime: Travel time for first stop must be zero + stopHeadsignPlaceholder: Outbound + stopHeadsignText: Stop headsign + stopHeadsignTitle: Headsign that overrides trip headsign between stops. + timepoint: Timepoint? + travelTimeHelp: Define the default time it takes to travel to this stop from + the previous stop. + PickupDropOffSelect: + available: Available (0) + continuousDropOffTitle: Indicates whether a rider can alight from the transit + vehicle at any point along the vehicle's travel path. + continuousPickupTitle: Indicates whether a rider can board the transit vehicle + anywhere along the vehicle's travel path. + continuousServiceDefault: (Inherit from route) + dropOffTitle: Define the dropff method/availability at this stop. + mustCoordinate: Must coordinate with driver to arrange (3) + mustPhoneAgency: Must phone agency to arrange (2) + notAvailable: Not available (1) + pickupDropOffDefault: (Default - Available) + pickupTitle: Define the pickup method/availability at this stop. + TripSeriesModal: + automaticallyUpdateTripIds: Automatically update Trip IDs for trips created in series? + close: Close + createTripSeriesBody: Enter the start and end time for the trip series (24 hour + time) and headway between trips. Click generate to create the series of trips. + createTripSeriesQuestion: Create a series of trips + disabledTooltip: There is an issue with the input data + endTime: "End Time:" + generateTrips: Generate Trips + headway: "Headway:" + incrementAmountPlaceholder: Increment amount + incrementBy: inc. by + incrementStartPlaceholder: Increment start (default 0) + prefixPlaceholder: Trip ID prefix (optional) + startTime: "Start Time:" diff --git a/lib/admin/components/AdminPage.js b/lib/admin/components/AdminPage.js index 0206fd9b7..ce27c9c15 100644 --- a/lib/admin/components/AdminPage.js +++ b/lib/admin/components/AdminPage.js @@ -22,6 +22,7 @@ import {getComponentMessages, isModuleEnabled} from '../../common/util/config' import * as projectActions from '../../manager/actions/projects' import type {DataToolsConfig, Project} from '../../types' import type {AppState, ManagerUserState, RouterProps} from '../../types/reducers' +import { AUTH0_DISABLED } from '../../common/constants' import OrganizationList from './OrganizationList' import ServerSettings from './ServerSettings' @@ -51,8 +52,8 @@ class AdminPage extends React.Component { } = this.props // Set default path to user admin view. if (!activeComponent) browserHistory.push('/admin/users') - // Always load a fresh list of users on load. - fetchUsers() + // Always load a fresh list of users on load, if auth is not disabled. + if (!AUTH0_DISABLED) fetchUsers() // Always load projects to prevent interference with public feeds viewer // loading of projects. fetchProjects() @@ -71,7 +72,8 @@ class AdminPage extends React.Component { const {activeComponent} = this.props const restricted =

Restricted access

switch (activeComponent) { - case 'users': return + case 'users': + return AUTH0_DISABLED ? restricted : case 'organizations': if (!isApplicationAdmin || isModuleEnabled('enterprise')) return restricted else return diff --git a/lib/admin/components/ApplicationStatus.js b/lib/admin/components/ApplicationStatus.js index 54ef02cdc..cd88479be 100644 --- a/lib/admin/components/ApplicationStatus.js +++ b/lib/admin/components/ApplicationStatus.js @@ -9,6 +9,7 @@ import BootstrapTable from 'react-bootstrap-table/lib/BootstrapTable' import TableHeaderColumn from 'react-bootstrap-table/lib/TableHeaderColumn' import * as adminActions from '../actions/admin' +import { AUTH0_DISABLED } from '../../common/constants' import {getComponentMessages} from '../../common/util/config' import {formatTimestamp} from '../../common/util/date-time' import type {ServerJob} from '../../types' @@ -114,14 +115,19 @@ class ApplicationStatusView extends Component { return })} -

{this.messages('userLogs')}

- + {!AUTH0_DISABLED && ( + <> +

{this.messages('userLogs')}

+ + + )} ) } diff --git a/lib/admin/components/OrganizationSettings.js b/lib/admin/components/OrganizationSettings.js index 729ed5252..3aef5f7b4 100644 --- a/lib/admin/components/OrganizationSettings.js +++ b/lib/admin/components/OrganizationSettings.js @@ -8,7 +8,7 @@ import moment from 'moment' import * as organizationActions from '../actions/organizations' import {getComponentMessages} from '../../common/util/config' -import toSentenceCase from '../../common/util/to-sentence-case' +import toSentenceCase from '../../common/util/text' import type {Organization, Project} from '../../types' type Props = { diff --git a/lib/alerts/components/AlertEditor.js b/lib/alerts/components/AlertEditor.js index 833f08298..e1230e845 100644 --- a/lib/alerts/components/AlertEditor.js +++ b/lib/alerts/components/AlertEditor.js @@ -15,7 +15,7 @@ import ManagerPage from '../../common/components/ManagerPage' import PageNotFound from '../../common/components/PageNotFound' import {isModuleEnabled} from '../../common/util/config' import {checkEntitiesForFeeds} from '../../common/util/permissions' -import toSentenceCase from '../../common/util/to-sentence-case' +import toSentenceCase from '../../common/util/text' import GtfsMapSearch from '../../gtfs/components/gtfsmapsearch' import GlobalGtfsFilter from '../../gtfs/containers/GlobalGtfsFilter' import {CAUSES, EFFECTS, isNew} from '../util' diff --git a/lib/alerts/components/AlertsList.js b/lib/alerts/components/AlertsList.js index d48a5cac1..135cac972 100644 --- a/lib/alerts/components/AlertsList.js +++ b/lib/alerts/components/AlertsList.js @@ -18,7 +18,7 @@ import AlertPreview from './AlertPreview' import Loading from '../../common/components/Loading' import OptionButton from '../../common/components/OptionButton' import { getFeedId } from '../../common/util/modules' -import toSentenceCase from '../../common/util/to-sentence-case' +import toSentenceCase from '../../common/util/text' import { FILTERS, SORT_OPTIONS } from '../util' import type {Props as ContainerProps} from '../containers/VisibleAlertsList' diff --git a/lib/common/actions/index.js b/lib/common/actions/index.js index b6cf4c266..b63d3bf14 100644 --- a/lib/common/actions/index.js +++ b/lib/common/actions/index.js @@ -1,6 +1,7 @@ // @flow import fetch from 'isomorphic-fetch' +import React from 'react' import {GTFS_GRAPHQL_PREFIX} from '../../common/constants' import {getComponentMessages} from '../../common/util/config' @@ -131,7 +132,23 @@ function getErrorMessageFromJson ( } // re-assign error message after it gets used in the detail. errorMessage = json.message || JSON.stringify(json) - if (json.detail.includes('conflicts with an existing trip id')) { + // TODO: REMOVE HACK + // This section (L135 - L146) is a hack to display links to patterns referenced by a stop to delete. + // This should be replaced with a better solution such as a new endpoint for this information. + if (json.detail && json.detail.includes('Referenced patterns:')) { + const patternSplit = json.detail.split(/\[(.*?)\]/) // Match text within square brackets (our comma separated list of patternIds) + detail = patternSplit[0] + const patternsMatch = patternSplit[1] + if (patternsMatch) { + const patterns = patternsMatch.split(',').map(pattern => { + pattern = pattern.slice(1, -1) // Remove curly braces + const [patternId, routeId] = pattern.split('-') + return {patternId, routeId} + }) + detail = + } + } + if (json.detail && json.detail.includes('conflicts with an existing trip id')) { action = 'DEFAULT' } } @@ -139,6 +156,42 @@ function getErrorMessageFromJson ( return { action, detail, message: errorMessage } } +/* + * This component displays an error message with links to individual patterns that are + * used by the stop being deleted. Use of this component is a hack itself, so this should + * be removed when a better solution is put in place. + * For discussion see original PR: https://github.com/ibi-group/datatools-ui/pull/943 + */ +const PatternLinkErrorMessage = (props) => { + const {detail, patterns} = props + // $FlowFixMe + return
+ {detail} + +
+} + function graphQLErrorsToString (errors: Array<{locations: any, message: string}>): Array { return errors.map(e => { const locations = e.locations.map(JSON.stringify).join(', ') diff --git a/lib/common/components/UserButtons.js b/lib/common/components/UserButtons.js index 2749e129e..b0f4a28ba 100644 --- a/lib/common/components/UserButtons.js +++ b/lib/common/components/UserButtons.js @@ -5,8 +5,9 @@ import { Button, ButtonToolbar } from 'react-bootstrap' import { LinkContainer } from 'react-router-bootstrap' import Icon from '@conveyal/woonerf/components/icon' -import {getComponentMessages} from '../../common/util/config' -import type {ManagerUserState} from '../../types/reducers' +import { AUTH0_DISABLED } from '../constants' +import { getComponentMessages } from '../../common/util/config' +import type { ManagerUserState } from '../../types/reducers' type Props = { logout: () => any, @@ -42,13 +43,16 @@ export default class UserButtons extends Component { )} - + {/* "Log out" Button (unless auth is disabled) */} + {!AUTH0_DISABLED && ( + + )} ) } diff --git a/lib/common/constants/index.js b/lib/common/constants/index.js index 44d714301..bf1ea8f15 100644 --- a/lib/common/constants/index.js +++ b/lib/common/constants/index.js @@ -15,6 +15,7 @@ export const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID || '' export const AUTH0_CONNECTION_NAME = process.env.AUTH0_CONNECTION_NAME || '' export const AUTH0_DEFAULT_SCOPE = 'app_metadata profile email openid user_metadata' export const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || '' +export const AUTH0_DISABLED = Boolean(process.env.DISABLE_AUTH) export const AUTO_DEPLOY_TYPES = Object.freeze({ ON_FEED_FETCH: 'ON_FEED_FETCH', diff --git a/lib/common/containers/App.js b/lib/common/containers/App.js index b3859a7ad..5ad5b2f9d 100644 --- a/lib/common/containers/App.js +++ b/lib/common/containers/App.js @@ -13,7 +13,7 @@ import MainAlertsViewer from '../../alerts/containers/MainAlertsViewer' import ActiveAlertEditor from '../../alerts/containers/ActiveAlertEditor' import Login from '../components/Login' import PageNotFound from '../components/PageNotFound' -import { AUTH0_CLIENT_ID, AUTH0_DEFAULT_SCOPE, AUTH0_DOMAIN } from '../constants' +import { AUTH0_CLIENT_ID, AUTH0_DEFAULT_SCOPE, AUTH0_DISABLED, AUTH0_DOMAIN } from '../constants' import ActiveGtfsEditor from '../../editor/containers/ActiveGtfsEditor' import ActiveFeedSourceViewer from '../../manager/containers/ActiveFeedSourceViewer' import ActiveProjectsList from '../../manager/containers/ActiveProjectsList' @@ -29,6 +29,7 @@ import type { dispatchFn, getStateFn } from '../../types/reducers' import ActiveUserRetriever from './ActiveUserRetriever' import AppInfoRetriever from './AppInfoRetriever' +import LocalUserRetriever from './LocalUserRetriever' import wrapComponentInAuthStrategy from './wrapComponentInAuthStrategy' function loginOptional (ComponentToWrap) { @@ -127,26 +128,35 @@ export default class App extends React.Component { path: '*' } ] - const routerWithAuth0 = ( - - + const appContent = ( + <> {routes.map((r, i) => ())} - + ) + const routerWithAuth0 = AUTH0_DISABLED ? <> + + {appContent} + + : ( + + + {appContent} + + ) // Initialize toast notifications. toast.configure() // Configure bugsnag if key is provided. diff --git a/lib/common/containers/LocalUserRetriever.js b/lib/common/containers/LocalUserRetriever.js new file mode 100644 index 000000000..9a21b83a7 --- /dev/null +++ b/lib/common/containers/LocalUserRetriever.js @@ -0,0 +1,60 @@ +// @flow +// $FlowFixMe useEffect not recognized by flow. +import { useEffect } from 'react' +import { connect } from 'react-redux' + +import * as userActions from '../../manager/actions/user' +import { AUTH0_CLIENT_ID } from '../constants' + +type Props = { + receiveTokenAndProfile: typeof userActions.receiveTokenAndProfile +} + +const profile = { + app_metadata: { + 'datatools': [ + { + 'permissions': [ + { + 'type': 'administer-application' + } + ], + 'projects': [], + 'client_id': AUTH0_CLIENT_ID, + 'subscriptions': [] + } + ], + 'roles': [ + 'user' + ] + }, + // FIXME: pick a better email address for both backend and frontend. + email: 'mock@example.com', + name: 'localuser', + nickname: 'Local User', + picture: 'https://d2tyb7byn1fef9.cloudfront.net/ibi_group_black-512x512.png', + sub: 'localuser', + user_id: 'localuser', + user_metadata: {} +} + +const token = 'local-user-token' + +/** + * This component provides a user profile for configs without authentication. + */ +const LocalUserRetriever = ({ receiveTokenAndProfile }: Props) => { + // Update the user info in the redux state on initialization. + useEffect(() => { + receiveTokenAndProfile({ profile, token }) + }, []) + + // Component renders nothing. + return null +} + +const mapDispatchToProps = { + receiveTokenAndProfile: userActions.receiveTokenAndProfile +} + +export default connect(null, mapDispatchToProps)(LocalUserRetriever) diff --git a/lib/common/containers/WatchButton.js b/lib/common/containers/WatchButton.js index 86702dcfa..99c899b59 100644 --- a/lib/common/containers/WatchButton.js +++ b/lib/common/containers/WatchButton.js @@ -7,8 +7,8 @@ import {connect} from 'react-redux' import * as userActions from '../../manager/actions/user' import * as statusActions from '../../manager/actions/status' import {getComponentMessages, getConfigProperty} from '../util/config' - import type {AppState, ManagerUserState} from '../../types/reducers' +import { AUTH0_DISABLED } from '../constants' type ContainerProps = { componentClass?: string, @@ -84,9 +84,12 @@ class WatchButton extends Component { } render () { + // Do not render watch button if notifications are not enabled or if auth is disabled. + // (Notifications require an email address verified with Auth0.) + if (AUTH0_DISABLED || !getConfigProperty('application.notifications_enabled')) { + return null + } const {componentClass} = this.props - // Do not render watch button if notifications are not enabled. - if (!getConfigProperty('application.notifications_enabled')) return null switch (componentClass) { case 'menuItem': return ( diff --git a/lib/common/containers/wrapComponentInAuthStrategy.js b/lib/common/containers/wrapComponentInAuthStrategy.js index 20f72ea89..ca1007f39 100644 --- a/lib/common/containers/wrapComponentInAuthStrategy.js +++ b/lib/common/containers/wrapComponentInAuthStrategy.js @@ -9,6 +9,7 @@ import { browserHistory } from 'react-router' import {getComponentMessages} from '../util/config' import type {AppState, ManagerUserState} from '../../types/reducers' +import { AUTH0_DISABLED } from '../constants' type AuthWrapperProps = { user: ManagerUserState @@ -68,7 +69,7 @@ export default function wrapComponentInAuthStrategy ( (state: AppState) => ({user: state.user}) )(AuthStrategyWrapper) - if (requireAuth || requireAdmin) { + if (!AUTH0_DISABLED && (requireAuth || requireAdmin)) { return withAuthenticationRequired(connectedComponent) } diff --git a/lib/common/util/maps.js b/lib/common/util/maps.js index 87f266949..7a4018707 100644 --- a/lib/common/util/maps.js +++ b/lib/common/util/maps.js @@ -33,17 +33,21 @@ export const EDITOR_MAP_LAYERS: Array = [ * Get the default Mapbox tile URL used for use in a leaflet map. Optionally * takes a map ID (e.g., mapbox/outdoors-v11). */ +// eslint-disable-next-line complexity export function defaultTileLayerProps (mapId: ?string): TileLayerProps { // If no mapId is provided, default to id defined in env.yml or, ultimately, // fall back on default value. const id = mapId || process.env.MAPBOX_MAP_ID || DEFAULT_MAP_ID const attribution = process.env.MAPBOX_ATTRIBUTION || `© Mapbox © OpenStreetMap Improve this map` const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN - if (!MAPBOX_ACCESS_TOKEN) { - throw new Error('Mapbox token not defined') + const MAP_BASE_URL = process.env.MAP_BASE_URL + if (!MAPBOX_ACCESS_TOKEN && !MAP_BASE_URL) { + throw new Error('One of Mapbox token or base url must be defined') } - const url = `https://api.mapbox.com/styles/v1/${id}/tiles/{z}/{x}/{y}${Browser.retina ? '@2x' : ''}?access_token=${MAPBOX_ACCESS_TOKEN}` - const retinaProps = Browser.retina + + const retina = window.retina || window.devicePixelRatio > 1 || Browser.retina + const url = process.env.MAP_BASE_URL || `https://api.mapbox.com/styles/v1/${id}/tiles/{z}/{x}/{y}${retina ? '@2x' : ''}?access_token=${MAPBOX_ACCESS_TOKEN || ''}` + const retinaProps = retina ? {tileSize: 512, zoomOffset: -1} : {} return { diff --git a/lib/common/util/text.js b/lib/common/util/text.js new file mode 100644 index 000000000..32fbd5528 --- /dev/null +++ b/lib/common/util/text.js @@ -0,0 +1,20 @@ +// @flow + +import toLower from 'lodash/toLower' +import upperFirst from 'lodash/upperFirst' + +export default function toSentenceCase (s: string): string { + return upperFirst(toLower(s)) +} + +/** + * This method takes a string like expires_in_7days and ensures + * that 7days is replaced with 7 days + */ +// $FlowFixMe flow needs to learn about new es2021 features! +export function spaceOutNumbers (s: string): string { + return s.replaceAll('_', ' ') + .split(/(?=[1-9])/) + .join(' ') + .toLowerCase() +} diff --git a/lib/common/util/to-sentence-case.js b/lib/common/util/to-sentence-case.js deleted file mode 100644 index 2bb811e5f..000000000 --- a/lib/common/util/to-sentence-case.js +++ /dev/null @@ -1,8 +0,0 @@ -// @flow - -import toLower from 'lodash/toLower' -import upperFirst from 'lodash/upperFirst' - -export default function toSentenceCase (s: string): string { - return upperFirst(toLower(s)) -} diff --git a/lib/editor/actions/map/__tests__/__snapshots__/stopStrategies.js.snap b/lib/editor/actions/map/__tests__/__snapshots__/stopStrategies.js.snap new file mode 100644 index 000000000..b7379e929 --- /dev/null +++ b/lib/editor/actions/map/__tests__/__snapshots__/stopStrategies.js.snap @@ -0,0 +1,174 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`editor > actions > stopStrategies updateShapeDistTraveled should populate distance traveled in a loop pattern 1`] = ` +Array [ + Object { + "defaultTravelTime": 0, + "id": 812, + "shapeDistTraveled": 0, + "stopId": "mni3", + "stopSequence": 0, + }, + Object { + "defaultTravelTime": 720, + "id": 813, + "shapeDistTraveled": 1267.0907200881181, + "stopId": "20", + "stopSequence": 1, + }, + Object { + "defaultTravelTime": 180, + "id": 814, + "shapeDistTraveled": 1621.2549997010212, + "stopId": "z745", + "stopSequence": 2, + }, + Object { + "defaultTravelTime": 1800, + "id": 815, + "shapeDistTraveled": 27816.018654027193, + "stopId": "56", + "stopSequence": 3, + }, + Object { + "defaultTravelTime": 1440, + "id": 816, + "shapeDistTraveled": 54970.153868786, + "stopId": "32", + "stopSequence": 4, + }, + Object { + "defaultTravelTime": 180, + "id": 817, + "shapeDistTraveled": 57221.67317745581, + "stopId": "mesm", + "stopSequence": 5, + }, + Object { + "defaultTravelTime": 360, + "id": 818, + "shapeDistTraveled": 73839.4199301234, + "stopId": "48", + "stopSequence": 6, + }, + Object { + "defaultTravelTime": 780, + "id": 819, + "shapeDistTraveled": 90255.00980161014, + "stopId": "41", + "stopSequence": 7, + }, + Object { + "defaultTravelTime": 360, + "id": 820, + "shapeDistTraveled": 91772.97104090628, + "stopId": "42", + "stopSequence": 8, + }, + Object { + "defaultTravelTime": 300, + "id": 821, + "shapeDistTraveled": 93027.691348872, + "stopId": "54", + "stopSequence": 9, + }, + Object { + "defaultTravelTime": 780, + "id": 822, + "shapeDistTraveled": 108704.22121540575, + "stopId": "106", + "stopSequence": 10, + }, + Object { + "defaultTravelTime": 240, + "id": 823, + "shapeDistTraveled": 114069.642131669, + "stopId": "2", + "stopSequence": 11, + }, + Object { + "defaultTravelTime": 360, + "id": 824, + "shapeDistTraveled": 122591.76955559291, + "stopId": "43", + "stopSequence": 12, + }, + Object { + "defaultTravelTime": 360, + "id": 825, + "shapeDistTraveled": 123703.67475483804, + "stopId": "104", + "stopSequence": 13, + }, + Object { + "defaultTravelTime": 360, + "id": 826, + "shapeDistTraveled": 127934.07012854327, + "stopId": "o6vb", + "stopSequence": 14, + }, + Object { + "defaultTravelTime": 600, + "id": 827, + "shapeDistTraveled": 138359.3114221102, + "stopId": "110", + "stopSequence": 15, + }, + Object { + "defaultTravelTime": 600, + "id": 829, + "shapeDistTraveled": 148784.5527156771, + "stopId": "95", + "stopSequence": 17, + }, + Object { + "defaultTravelTime": 300, + "id": 830, + "shapeDistTraveled": 164871.9601365662, + "stopId": "24", + "stopSequence": 18, + }, + Object { + "defaultTravelTime": 240, + "id": 831, + "shapeDistTraveled": 181433.9825137495, + "stopId": "31", + "stopSequence": 19, + }, + Object { + "defaultTravelTime": 0, + "id": 828, + "shapeDistTraveled": 181975.28021083935, + "stopId": "87", + "stopSequence": 16, + }, + Object { + "defaultTravelTime": 180, + "id": 832, + "shapeDistTraveled": 183447.92710467172, + "stopId": "20", + "stopSequence": 20, + }, + Object { + "defaultTravelTime": 420, + "id": 833, + "shapeDistTraveled": 183802.09138428463, + "stopId": "z745", + "stopSequence": 21, + }, + Object { + "defaultTravelTime": 180, + "id": 834, + "shapeDistTraveled": 184825.47542408676, + "stopId": "19", + "stopSequence": 22, + }, + Object { + "defaultTravelTime": 60, + "id": 835, + "shapeDistTraveled": 184921.39475283914, + "stopId": "mni3", + "stopSequence": 23, + }, +] +`; diff --git a/lib/editor/actions/map/__tests__/fixtures/loop-ctrl-points.json b/lib/editor/actions/map/__tests__/fixtures/loop-ctrl-points.json new file mode 100644 index 000000000..44822c5ec --- /dev/null +++ b/lib/editor/actions/map/__tests__/fixtures/loop-ctrl-points.json @@ -0,0 +1,362 @@ +[ + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.17946, + 44.58992 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 0, + "stopId": "mni3" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.16837, + 44.59813 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 1785.5086062938587, + "stopId": "20" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.16795, + 44.59496 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 2308.139820073338, + "stopId": "z745" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.46049, + 44.70518 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 33048.206330519366, + "stopId": "56" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.19932, + 44.86404 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 60832.118540076735, + "stopId": "32" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.17551, + 44.87522 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 63146.79332096664, + "stopId": "mesm" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.99228, + 44.80147 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 94015.21260408717, + "stopId": "48" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.90885, + 44.93669 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 114286.01606753658, + "stopId": "41" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.89142, + 44.93086 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 116276.2886381317, + "stopId": "42" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.88625, + 44.92019 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 118373.73385166784, + "stopId": "54" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.97786, + 44.83535 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 132847.66707278747, + "stopId": "106" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.98327, + 44.79718 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 137118.58207316347, + "stopId": "2" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.99367, + 44.74951 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 142538.16195792932, + "stopId": "43" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.97398, + 44.67418 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 152361.6373537682, + "stopId": "104" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.98569, + 44.66865 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 153703.28017251854, + "stopId": "o6vb" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.02729, + 44.66202 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 159323.18716881506, + "stopId": "110" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.13191, + 44.60509 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 179100.23443395202, + "stopId": "95" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.15313, + 44.59666 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 189971.7871304524, + "stopId": "24" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.15024, + 44.60107 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 191943.80449697602, + "stopId": "31" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -74.98491, + 44.68523 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 175208.868318191, + "stopId": "87" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.16837, + 44.59813 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 192867.72434468954, + "stopId": "20" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.16795, + 44.59496 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 195244.20635300872, + "stopId": "z745" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.17838, + 44.58953 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 195766.8375667882, + "stopId": "19" + }, + { + "point": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -75.17946, + 44.58992 + ] + } + }, + "pointType": 2, + "shapeDistTraveled": 197242.5454472529, + "stopId": "mni3" + } +] diff --git a/lib/editor/actions/map/__tests__/fixtures/loop-pattern-segments.json b/lib/editor/actions/map/__tests__/fixtures/loop-pattern-segments.json new file mode 100644 index 000000000..dcca2b591 --- /dev/null +++ b/lib/editor/actions/map/__tests__/fixtures/loop-pattern-segments.json @@ -0,0 +1,232 @@ +[ + [ + [ + -75.17946, + 44.58992 + ], + [ + -75.16837, + 44.59813 + ] + ], + [ + [ + -75.16837, + 44.59813 + ], + [ + -75.16795, + 44.59496 + ] + ], + [ + [ + -75.16795, + 44.59496 + ], + [ + -75.46049, + 44.70518 + ] + ], + [ + [ + -75.46049, + 44.70518 + ], + [ + -75.19932, + 44.86404 + ] + ], + [ + [ + -75.19932, + 44.86404 + ], + [ + -75.17551, + 44.87522 + ] + ], + [ + [ + -75.17551, + 44.87522 + ], + [ + -74.99228, + 44.80147 + ] + ], + [ + [ + -74.99228, + 44.80147 + ], + [ + -74.90885, + 44.93669 + ] + ], + [ + [ + -74.90885, + 44.93669 + ], + [ + -74.89142, + 44.93086 + ] + ], + [ + [ + -74.89142, + 44.93086 + ], + [ + -74.88625, + 44.92019 + ] + ], + [ + [ + -74.88625, + 44.92019 + ], + [ + -74.98327, + 44.79717 + ] + ], + [ + [ + -74.98327, + 44.79718 + ], + [ + -74.99366, + 44.74951 + ] + ], + [ + [ + -74.99367, + 44.74951 + ], + [ + -74.97399, + 44.67418 + ] + ], + [ + [ + -74.97398, + 44.67418 + ], + [ + -74.98569, + 44.66865 + ] + ], + [ + [ + -75.02729, + 44.66202 + ], + [ + -74.98491, + 44.68522 + ] + ], + [ + [ + -75.02729, + 44.66202 + ], + [ + -75.13192, + 44.60509 + ] + ], + [ + [ + -75.02729, + 44.66202 + ], + [ + -75.13192, + 44.60509 + ] + ], + [ + [ + -75.15024, + 44.60107 + ], + [ + -74.98491, + 44.68522 + ] + ], + [ + [ + -74.98491, + 44.68523 + ], + [ + -75.15313, + 44.59666 + ] + ], + [ + [ + -75.15313, + 44.59666 + ], + [ + -75.15024, + 44.60107 + ] + ], + [ + [ + -75.15024, + 44.60107 + ], + [ + -75.16837, + 44.59813 + ] + ], + [ + [ + -75.16837, + 44.59813 + ], + [ + -75.16795, + 44.59496 + ] + ], + [ + [ + -75.16795, + 44.59496 + ], + [ + -75.17838, + 44.58953 + ] + ], + [ + [ + -75.17838, + 44.58953 + ], + [ + -75.17946, + 44.58992 + ] + ] +] diff --git a/lib/editor/actions/map/__tests__/fixtures/loop-pattern-stops.json b/lib/editor/actions/map/__tests__/fixtures/loop-pattern-stops.json new file mode 100644 index 000000000..742e25e65 --- /dev/null +++ b/lib/editor/actions/map/__tests__/fixtures/loop-pattern-stops.json @@ -0,0 +1,170 @@ +[ + { + "id": 812, + "stopId": "mni3", + "defaultTravelTime": 0, + "stopSequence": 0, + "shapeDistTraveled": 197242.5454472529 + }, + { + "id": 813, + "stopId": "20", + "defaultTravelTime": 720, + "stopSequence": 1, + "shapeDistTraveled": 192867.72434468954 + }, + { + "id": 814, + "stopId": "z745", + "defaultTravelTime": 180, + "stopSequence": 2, + "shapeDistTraveled": 195244.20635300872 + }, + { + "id": 815, + "stopId": "56", + "defaultTravelTime": 1800, + "stopSequence": 3, + "shapeDistTraveled": 33048.206330519366 + }, + { + "id": 816, + "stopId": "32", + "defaultTravelTime": 1440, + "stopSequence": 4, + "shapeDistTraveled": 60832.118540076735 + }, + { + "id": 817, + "stopId": "mesm", + "defaultTravelTime": 180, + "stopSequence": 5, + "shapeDistTraveled": 63146.79332096664 + }, + { + "id": 818, + "stopId": "48", + "defaultTravelTime": 360, + "stopSequence": 6, + "shapeDistTraveled": 94015.21260408717 + }, + { + "id": 819, + "stopId": "41", + "defaultTravelTime": 780, + "stopSequence": 7, + "shapeDistTraveled": 114286.01606753658 + }, + { + "id": 820, + "stopId": "42", + "defaultTravelTime": 360, + "stopSequence": 8, + "shapeDistTraveled": 116276.2886381317 + }, + { + "id": 821, + "stopId": "54", + "defaultTravelTime": 300, + "stopSequence": 9, + "shapeDistTraveled": 118373.73385166784 + }, + { + "id": 822, + "stopId": "106", + "defaultTravelTime": 780, + "stopSequence": 10, + "shapeDistTraveled": 132847.66707278747 + }, + { + "id": 823, + "stopId": "2", + "defaultTravelTime": 240, + "stopSequence": 11, + "shapeDistTraveled": 137118.58207316347 + }, + { + "id": 824, + "stopId": "43", + "defaultTravelTime": 360, + "stopSequence": 12, + "shapeDistTraveled": 142538.16195792932 + }, + { + "id": 825, + "stopId": "104", + "defaultTravelTime": 360, + "stopSequence": 13, + "shapeDistTraveled": 152361.6373537682 + }, + { + "id": 826, + "stopId": "o6vb", + "defaultTravelTime": 360, + "stopSequence": 14, + "shapeDistTraveled": 153703.28017251854 + }, + { + "id": 827, + "stopId": "110", + "defaultTravelTime": 600, + "stopSequence": 15, + "shapeDistTraveled": 159323.18716881506 + }, + { + "id": 829, + "stopId": "95", + "defaultTravelTime": 600, + "stopSequence": 17, + "shapeDistTraveled": 179100.23443395202 + }, + { + "id": 830, + "stopId": "24", + "defaultTravelTime": 300, + "stopSequence": 18, + "shapeDistTraveled": 189971.7871304524 + }, + { + "id": 831, + "stopId": "31", + "defaultTravelTime": 240, + "stopSequence": 19, + "shapeDistTraveled": 191943.80449697602 + }, + { + "id": 828, + "stopId": "87", + "defaultTravelTime": 0, + "stopSequence": 16, + "shapeDistTraveled": 175208.868318191 + }, + { + "id": 832, + "stopId": "20", + "defaultTravelTime": 180, + "stopSequence": 20, + "shapeDistTraveled": 157563.46609751563 + }, + { + "id": 833, + "stopId": "z745", + "defaultTravelTime": 420, + "stopSequence": 21, + "shapeDistTraveled": 158085.93401876837 + }, + { + "id": 834, + "stopId": "19", + "defaultTravelTime": 180, + "stopSequence": 22, + "shapeDistTraveled": 195766.8375667882 + }, + { + "id": 835, + "stopId": "mni3", + "defaultTravelTime": 60, + "stopSequence": 23, + "shapeDistTraveled": 159657.88186977178 + } +] diff --git a/lib/editor/actions/map/__tests__/stopStrategies.js b/lib/editor/actions/map/__tests__/stopStrategies.js new file mode 100644 index 000000000..d48fcf25d --- /dev/null +++ b/lib/editor/actions/map/__tests__/stopStrategies.js @@ -0,0 +1,35 @@ +import clone from 'lodash/cloneDeep' + +import { updateShapeDistTraveled } from '../stopStrategies' + +const loopControlPoints = require('./fixtures/loop-ctrl-points.json') +const loopPatternSegments = require('./fixtures/loop-pattern-segments.json') +const loopPatternStops = require('./fixtures/loop-pattern-stops.json') + +describe('editor > actions > stopStrategies', () => { + describe('updateShapeDistTraveled', () => { + it('should populate distance traveled in a loop pattern', () => { + const clonedPatternStops = clone(loopPatternStops) + updateShapeDistTraveled(loopControlPoints, loopPatternSegments, clonedPatternStops) + + expect(clonedPatternStops[0].shapeDistTraveled).toBe(0) + + // Traveled distance should be in strict ascending order. + for (let i = 1; i < clonedPatternStops.length; i++) { + expect(clonedPatternStops[i].shapeDistTraveled).toBeGreaterThan(clonedPatternStops[i - 1].shapeDistTraveled) + } + + // The pattern contains a loop, so the total distance traveled from pattern start + // should be populated as opposed to the distance when hitting a repeated stop the first time. + expect(clonedPatternStops[23].stopId).toBe(clonedPatternStops[0].stopId) + expect(clonedPatternStops[23].shapeDistTraveled).toBe(184921.39475283914) + + expect(clonedPatternStops[21].stopId).toBe(clonedPatternStops[2].stopId) + expect(clonedPatternStops[2].shapeDistTraveled).toBe(1621.2549997010212) + expect(clonedPatternStops[21].shapeDistTraveled).toBe(183802.09138428463) + + // See other individual computed traveled distances in snapshot. + expect(clonedPatternStops).toMatchSnapshot() + }) + }) +}) diff --git a/lib/editor/actions/map/index.js b/lib/editor/actions/map/index.js index f79304923..facc0ba75 100644 --- a/lib/editor/actions/map/index.js +++ b/lib/editor/actions/map/index.js @@ -10,8 +10,9 @@ import lineString from 'turf-linestring' import nearestPointOnLine from '@turf/nearest-point-on-line' import point from 'turf-point' +import {updatePatternStops, setActivePatternSegment} from '../tripPattern' +import {saveActiveGtfsEntity} from '../active' import {POINT_TYPE} from '../../constants' -import {setActivePatternSegment} from '../tripPattern' import {setErrorMessage} from '../../../manager/actions/status' import { getLineSlices, @@ -22,6 +23,8 @@ import { import type {Feed, LatLng, Pattern, ControlPoint} from '../../../types' import type {dispatchFn, getStateFn} from '../../../types/reducers' +import {updateShapeDistTraveled} from './stopStrategies' + export const controlPointDragOrEnd = createAction( 'CONTROL_POINT_DRAG_START_OR_END', (payload: void | string) => payload @@ -248,6 +251,7 @@ export function removeControlPoint (controlPoints: Array, index: n const { coordinates, updatedControlPoints + // $FlowFixMe: Flow does not recognize patterns within returned Promise type } = await recalculateShape({ avoidMotorways, controlPoints, @@ -256,10 +260,18 @@ export function removeControlPoint (controlPoints: Array, index: n followStreets, patternCoordinates }) + + // Update the shape_dist_traveled values to reflect the new pattern that the bus follows + updateShapeDistTraveled(updatedControlPoints, coordinates, pattern.patternStops) + // Update active pattern in store (does not save to server). dispatch(updatePatternGeometry({ controlPoints: updatedControlPoints, patternSegments: coordinates })) + + // Ensure that all updates are reflected in exports. + dispatch(updatePatternStops(pattern, pattern.patternStops)) + dispatch(saveActiveGtfsEntity('trippattern')) } } diff --git a/lib/editor/actions/map/stopStrategies.js b/lib/editor/actions/map/stopStrategies.js index bc19e3236..cc49a23b3 100644 --- a/lib/editor/actions/map/stopStrategies.js +++ b/lib/editor/actions/map/stopStrategies.js @@ -12,27 +12,26 @@ import point from 'turf-point' import {updateActiveGtfsEntity, saveActiveGtfsEntity} from '../active' import {updatePatternStops} from '../tripPattern' import {generateUID} from '../../../common/util/util' +import { getControlPointsForStops } from '../../components/map/pattern-debug-lines' import {POINT_TYPE} from '../../constants' import {newGtfsEntity} from '../editor' import {setErrorMessage} from '../../../manager/actions/status' import {updatePatternGeometry} from '../map' import {getControlPoints} from '../../selectors' -import {polyline as getPolyline} from '../../../scenario-editor/utils/valhalla' +import {getSegment, polyline as getPolyline} from '../../../scenario-editor/utils/valhalla' import {getTableById} from '../../util/gtfs' -import { +import {stopToGeoJSONPoint, constructStop, controlPointsFromSegments, newControlPoint, stopToPatternStop, recalculateShape, getPatternEndPoint, - projectStopOntoLine, street, stopToPoint, constructPoint } from '../../util/map' -import {coordinatesFromShapePoints} from '../../util/objects' -import type {ControlPoint, GtfsStop, LatLng, Pattern} from '../../../types' +import type {ControlPoint, Coordinates, GtfsStop, LatLng, Pattern, PatternStop, StopControlPoint} from '../../../types' import type {dispatchFn, getStateFn} from '../../../types/reducers' /** @@ -60,6 +59,14 @@ export function addStopAtPoint ( } } +/** + * Adds values in range to array. Used to help add elements to controlPoints array + */ +const addRangeToArray = (array: Array, startIndex: number, lengthToAdd: number): Array => { + for (let i = 0; i < lengthToAdd; i++) { array.push(startIndex + i) } + return array +} + /** * Creates new stops at intersections according to edit settings (e.g., distance * from intersection, whether it should be on the near or far side of the @@ -108,7 +115,7 @@ export function addStopAtIntersection ( ...trimmed.geometry.coordinates ] } - // $FlowFixMe lots of flow errors on setting pattern as entity. + // $FlowFixMe dispatch(updateActiveGtfsEntity({ component: 'trippattern', entity: activePattern, @@ -226,7 +233,6 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num const {patternStops: currentPatternStops, shapePoints} = pattern const patternStops = clone(currentPatternStops) const {controlPoints, patternSegments} = getControlPoints(getState()) - const patternLine = lineString(coordinatesFromShapePoints(shapePoints)) const hasShapePoints = shapePoints && shapePoints.length > 1 const newStop = stopToPatternStop( stop, @@ -234,7 +240,6 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num ? patternStops.length : index ) - if (typeof index === 'undefined' || index === null || index === patternStops.length) { // Push pattern stop to cloned list. patternStops.push(newStop) @@ -287,49 +292,67 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num if (hasShapePoints) { // Update shape if it exists. No need to update anything besides pattern // stops (which already occurred above) if there is no shape. NOTE: the - // behavior in this code block essentially replaces any surrounding - // non-stop control points with the new pattern stop and re-routes the - // shape between it and the surrounding stop control points. - // Find projected location onto pattern shape. - const {distanceInMeters, insertPoint} = projectStopOntoLine(stop, patternLine) + // behavior in this code block essentially replaces any + // non-stop control points in the "from" segment with the new pattern stop and re-routes the + // shape between it and the new stop. + // Non-stop control points are preserved in the new "to" segment. + // ↙ "from" segment ↙ "to" segment + // 0 ——x——— 0 ——————————x———x——— 0 + // ^— 0 —^ <--- new inserted stop + // Add control point in order to copy of current list. - const controlPoint = newControlPoint(distanceInMeters, insertPoint, { - stopId: stop.stop_id, - pointType: POINT_TYPE.STOP // 2 - }) - const stopControlPoints = controlPoints - // TODO: refactor into shared function (see pattern-debug-lines.js) - .map((cp, index) => ({...cp, cpIndex: index})) - .filter(cp => cp.pointType === POINT_TYPE.STOP) - const {cpIndex: nextStopIndex} = stopControlPoints[index] - // Iterate over control points to find previous and next stop control - // points. - let spliceIndex = 0 - while (controlPoints[spliceIndex].distance < distanceInMeters && spliceIndex < nextStopIndex) { - spliceIndex++ - } + const stopControlPoints = getControlPointsForStops(controlPoints) + // Perform splice operation on cloned control points to remove any // control points between the previous and next stop control points and // insert new stop control point in their stead. const clonedControlPoints = clone(controlPoints) const clonedPatternSegments = clone(patternSegments) - // Replace n segments with 2 blank "placeholder" segments to be replaced - // with new routed segments. - clonedControlPoints.splice(spliceIndex, 0, controlPoint) - const prev = clonedControlPoints[spliceIndex - 1] - const next = clonedControlPoints[spliceIndex + 1] - const segmentSpliceIndex = spliceIndex - 1 - clonedPatternSegments.splice( - segmentSpliceIndex, - 1, - [prev.point.geometry.coordinates, insertPoint.geometry.coordinates], - [insertPoint.geometry.coordinates, next.point.geometry.coordinates] - ) - // console.log(`splicing control points at ${spliceIndex}. Replacing ${controlPointsToRemove}`, controlPoints, clonedControlPoints) - // console.log(`splicing segments at ${segmentSpliceIndex}. Replacing ${segmentsToRemove}`, patternSegments, clonedPatternSegments) - // Recalculate shape + + // Insert the new stop after the previous stopControlPoint. + // We assume that all control points hold for the "to" semgent (see above). + const previousStopControlPoint = stopControlPoints[index - 1] + const spliceIndex = previousStopControlPoint.cpIndex + 1 + const nextControlPoint = clonedControlPoints[spliceIndex] + + // Previously, at this point we had created our control point by projecting the stop onto the existing shape. + // However, this created problems for insertions where the stop is geographically before the previous stop + // but is being inserted after it in the stop sequence. We were projecting previously bc to create a control point + // we need to know the distance along the shape, but to avoid the above issue we need to make a sub-request to graphhopper + // to get the segment between the two points and from that, the distance. + const insertPoint = stopToGeoJSONPoint(stop) + const newFromSegmentCoords = [ + previousStopControlPoint.point.geometry.coordinates, + [stop.stop_lon, stop.stop_lat] + ] + + // We wrap more of the code in this try block because the pre-calculated "newFromSegment" can fail in the same way + // as recalculate shape. let result try { + const newFromSegment = await getSegment(newFromSegmentCoords, true) + const segmentDistance = lineDistance(newFromSegment, 'meters') + const addedControlPoint = newControlPoint( + previousStopControlPoint.distance + segmentDistance, + insertPoint, + { + stopId: stop.stop_id, + pointType: POINT_TYPE.STOP // 2 + }) + // Instead of splicing at the previousStopControlPoint, splice immediately after. + clonedControlPoints.splice(spliceIndex, 0, addedControlPoint) + + // Replace n segments with 2 segments to be replaced + // with new routed segments. + clonedPatternSegments.splice( + previousStopControlPoint.cpIndex, + 1, + [previousStopControlPoint.point.geometry.coordinates, insertPoint.geometry.coordinates], + [insertPoint.geometry.coordinates, nextControlPoint.point.geometry.coordinates] + ) + + // TODO: Because we are pre-generating the from segment above, we could use that in the final recalculated shape. + // Right now, bc of the legacy method we are repeating that small amount of work. result = await recalculateShape({ avoidMotorways, controlPoints: clonedControlPoints, @@ -502,10 +525,12 @@ export function removeStopFromPattern (pattern: Pattern, stop: GtfsStop, index: if (!shapePoints || shapePoints.length === 0) { // If pattern has no shape points, don't attempt to refactor pattern shape console.log('pattern coordinates do not exist') + patternStops.splice(index, 1) } else { const {avoidMotorways, followStreets} = getState().editor.editSettings.present let result try { + // $FlowFixMe: Flow does not recognize controlpoints within returned Promise type result = await recalculateShape({ avoidMotorways, controlPoints: clonedControlPoints, @@ -525,15 +550,301 @@ export function removeStopFromPattern (pattern: Pattern, stop: GtfsStop, index: dispatch(setErrorMessage({message: `Could not remove stop from pattern:`})) return } + patternStops.splice(index, 1) + // Update the shape_dist_traveled values if we're removing the first stop (no control point will be left in this case) + if (index === 0) updateShapeDistTraveled(result.updatedControlPoints, result.coordinates, patternStops) + // Update pattern geometry dispatch(updatePatternGeometry({ controlPoints: result.updatedControlPoints, patternSegments: result.coordinates })) } - // Update pattern stops (whether or not geometry exists) - patternStops.splice(index, 1) dispatch(updatePatternStops(pattern, patternStops)) dispatch(saveActiveGtfsEntity('trippattern')) } } + +/** + * Based on the provided starting point for deletion and the number of segments until we hit another stop, + * this function calculates the number of control points and adds the control points and segments to the array + * for later deletion. + */ +const updateControlPointsAndSegments = (deletedControlPoints: Array, deletedSegments: Array, deletionStartIndex: number, numberSegments: number): Array> => { + // Each control point creates 2 segments, so you have one less control point than segments. + const numberControlPoints = numberSegments - 1 + // Add control points and segments to the list to delete and return + return [ + addRangeToArray(deletedControlPoints, deletionStartIndex + 1, numberControlPoints), // Add one for immediate next control point. + addRangeToArray(deletedSegments, deletionStartIndex, numberSegments) + ] +} + +/** + * Generates a new segment for the fromPoint and toPoint and inserts it into the new patternSegments array. + */ +const generateSegmentAndInsert = async (segments: Array, fromPoint: ?ControlPoint, toPoint: ?ControlPoint, insertionPoint: number): Promise => { + if (!fromPoint || !toPoint) return + const clonedSegments = clone(segments) + const points = [fromPoint.point.geometry.coordinates, toPoint.point.geometry.coordinates] + const segment = await getSegment(points, true) + if (segment && segment.coordinates) clonedSegments.splice(insertionPoint, 0, segment.coordinates) + return clonedSegments +} + +/** + * Updates the shape_dist_traveled values for each control point after a change in the pattern has occurred. + * These values must also be updated in the patternStops which will be used to create the shapeDistTraveled + * values in stop_times.txt + */ +export const updateShapeDistTraveled = (controlPoints: Array, segments: Array, patternStops: Array): void => { + // $FlowFixMe + const stopControlPoints = getControlPointsForStops(controlPoints) + + let newShapeDistTraveled = 0.0 + const unusedPatternStops = [...patternStops] + stopControlPoints.forEach((cp, index) => { + const {stopId, cpIndex} = cp + if (index !== 0) { + // previous cp.shape_dist_traveled + all segment distances between + const previousStopCP = stopControlPoints[index - 1] + // $FlowFixMe + newShapeDistTraveled = controlPoints[previousStopCP.cpIndex].shapeDistTraveled + for (let i = previousStopCP.cpIndex; i < cpIndex; i++) { + const segmentDistance = lineDistance(lineString(segments[i]), 'meters') + newShapeDistTraveled += segmentDistance + } + } + // $FlowFixMe + controlPoints[cpIndex].shapeDistTraveled = newShapeDistTraveled + // $FlowFixMe . FIXME: distance should be equal to the shapeDistTraveled for a stop... it was slightly different before. + controlPoints[cpIndex].distance = newShapeDistTraveled + + const patternStopIdx = unusedPatternStops.findIndex(el => el && el.stopId === stopId) + if (patternStopIdx !== -1) { + patternStops[patternStopIdx].shapeDistTraveled = newShapeDistTraveled + // "Erase" that pattern stop, without changing order, + // so that it doesn't get used again if a pattern visits a stop multiple times. + unusedPatternStops[patternStopIdx] = null + } + }) +} + +/** + * Method to remove old segments and control points and backfill with new segment between stops on either side. + * TODO: Refactor this method to make things cleaner. + */ +const removeOldSegments = ( + previousFromStopControlPoint: ?StopControlPoint, + previousToStopControlPoint: ?StopControlPoint, + deletedSegments: Array, + deletedControlPoints: Array, + stopControlPoints: Array, + movedFromStart: boolean, + movedFromEnd: boolean, + movedStopControlPoint: StopControlPoint, + oldPatternStopSequence: number +) => { + // O --X---> OLD POSITION --X--> O + // O --------------------------> O + if (!movedFromEnd) { + // Delete old "to" segment control points and segments, no "to" segment if we're moving from the end + // $FlowFixMe + const previousToSegments = previousToStopControlPoint.cpIndex - movedStopControlPoint.cpIndex; // Semi-colon for babel parsing. + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, movedStopControlPoint.cpIndex, previousToSegments) + } + + if (!movedFromStart) { + // Delete old "from" segment control points and segments, no "from" segment if we're moving from the start + // $FlowFixMe + const previousFromSegments = movedStopControlPoint.cpIndex - previousFromStopControlPoint.cpIndex; // Semi-colon for babel parsing. + // $FlowFixMe + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, previousFromStopControlPoint.cpIndex, previousFromSegments) + } + + return [deletedControlPoints, deletedSegments] +} + +/** + * Method to remove a segment and insert a new one that points to the new stop that is being inserted. + * TODO: Refactor this method to make things cleaner. + */ +const removeNewSegments = ( + deletedControlPoints: Array, + deletedSegments: Array, + movedForward: boolean, + movedToEnd: boolean, + movedToStart: boolean, + newPatternStopSequence: number, + newToStopControlPoint: StopControlPoint, + stopControlPoints: Array +) => { + // The segment that we remove depends on if we are moving forwards or backwards (see diagram). If we're moving forwards we need to modify the old "to" segment, if we're moving backwards the "from" segment. + // Case: moving backward Case: moving forward + // 0 --x--> 0 ------> 0 -------> 0 0 -----> 0 ------> 0 ---x---> 0 + // ↑←←←←←←←←←←←←←←←←←←←←←← 0 0 →→→→→→→→→→→→→→→→→→→↑ + // We need to initialize newToStopControlPoint to their default values if the first check fails (they are still used later if we move to start or end) + if (!movedToStart && !movedToEnd) { // When you moved to the start or end, there's no previous segment to remove, only one to add. + let newSegmentsFromStop // This may differ from the fromStopControlPoint defined earlier because of the backwards and forwards cases for new segment deletion. + if (movedForward) { + newSegmentsFromStop = stopControlPoints[newPatternStopSequence + 1] + const numberNewSegments = newToStopControlPoint.cpIndex - newSegmentsFromStop.cpIndex; // Semi colon for babel parsing. + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, newSegmentsFromStop.cpIndex, numberNewSegments) + } else { // moved backward + newSegmentsFromStop = stopControlPoints[newPatternStopSequence - 1] + const numberNewSegments = newToStopControlPoint.cpIndex - newSegmentsFromStop.cpIndex; + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, newSegmentsFromStop.cpIndex, numberNewSegments) + } + } + return [deletedControlPoints, deletedSegments] +} + +/** + * Method to insert the new segment replacing the segments in the "old" position, the new from segment, and the new to segment if they require insertion. + */ +const insertSegments = async ( + clonedPatternSegments: Array, + insertionPoint: number, + movedFromEnd: boolean, + movedFromStart: boolean, + movedStopControlPoint: StopControlPoint, + movedToEnd: boolean, + movedToStart: boolean, + newFromStopControlPoint: StopControlPoint, + newToStopControlPoint: StopControlPoint, + previousFromStopControlPoint: StopControlPoint, + previousToStopControlPoint: StopControlPoint +) => { + // Insert the rearranged old segment, there is none if we moved from the start or the end + if (!movedFromStart && !movedFromEnd && previousFromStopControlPoint) { // previousFromStopControlPoint check to make flow happy + clonedPatternSegments = await generateSegmentAndInsert(clonedPatternSegments, previousFromStopControlPoint, previousToStopControlPoint, previousFromStopControlPoint.cpIndex) + } + // Insert the new from segment + if (!movedToStart) { + clonedPatternSegments = await generateSegmentAndInsert(clonedPatternSegments, newFromStopControlPoint, movedStopControlPoint, newFromStopControlPoint.cpIndex) + } + // Insert the new to segment + if (!movedToEnd) { + clonedPatternSegments = await generateSegmentAndInsert(clonedPatternSegments, movedStopControlPoint, newToStopControlPoint, insertionPoint) + } + // $FlowFixMe + return clonedPatternSegments +} + +/** + * Updates the shapes (segments and control points) after the pattern has been reordered using the pattern stop card dragging feature. + * This method will override several of the segments around the old location of the moved stop, and around + * the new location where the stop is being moved to. + */ +export function updateShapesAfterPatternReorder (oldPattern: Pattern, newPatternStops: Array, oldPatternStopSequence: number) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + const {controlPoints, patternSegments} = getControlPoints(getState()) + + // Use object copies of the arrays to preserve indices, we'll convert back after juggling deletions + let clonedControlPoints = clone(controlPoints) + let clonedPatternSegments = clone(patternSegments) + + const stopControlPoints = getControlPointsForStops(controlPoints) + + const newPatternStopSequence = newPatternStops.findIndex(pStop => pStop.stopSequence === oldPatternStopSequence) + + const movedFromStart = oldPatternStopSequence === 0 + const movedFromEnd = oldPatternStopSequence === newPatternStops.length - 1 + const movedToStart = newPatternStopSequence === 0 + const movedToEnd = newPatternStopSequence === newPatternStops.length - 1 + const movedForward = oldPatternStopSequence < newPatternStopSequence + const movedAdjacent = Math.abs(newPatternStopSequence - oldPatternStopSequence) + + const movedStopControlPoint: StopControlPoint = stopControlPoints[oldPatternStopSequence] + let deletedControlPoints: Array = [] + let deletedSegments: Array = [] + const previousFromStopControlPoint: StopControlPoint = stopControlPoints[oldPatternStopSequence - 1] // Null if moved from beginning + const previousToStopControlPoint: StopControlPoint = stopControlPoints[oldPatternStopSequence + 1] + // With the "new" segments in adjacent moves, we need to account for the from and to stops referencing themselves + const newFromStopControlPoint: StopControlPoint = stopControlPoints[movedAdjacent && movedForward ? newPatternStopSequence : newPatternStopSequence - 1] + const newToStopControlPoint: StopControlPoint = stopControlPoints[movedAdjacent && !movedForward ? newPatternStopSequence : newPatternStopSequence + 1]; // Semi-colon for babel parsing. + + // 1. Remove appropriate segments in the "old" position of the stop to move. + [deletedControlPoints, deletedSegments] = removeOldSegments( + previousFromStopControlPoint, + previousToStopControlPoint, + deletedSegments, + deletedControlPoints, + // $FlowFixMe + stopControlPoints, + movedFromStart, + movedFromEnd, + movedStopControlPoint, + oldPatternStopSequence + ); + + // 2. Remove appropriate segments in the "new" position of the moved stop (i.e. the destination) + [deletedControlPoints, deletedSegments] = removeNewSegments( + deletedControlPoints, + deletedSegments, + movedForward, + movedToEnd, + movedToStart, + newPatternStopSequence, + newToStopControlPoint, + // $FlowFixMe + stopControlPoints + ) + + // 3. Set our cloned sets of control points and segments to null to avoid juggling array indices. + // $FlowFixMe + deletedControlPoints.forEach(deletedCPIndex => { clonedControlPoints[deletedCPIndex] = null }) + // $FlowFixMe + deletedSegments.forEach(deletedSegmentIndex => { clonedPatternSegments[deletedSegmentIndex] = null }) + + // 4. Insert appropriate segments at the new and old locations + const insertionPoint = movedToStart ? 0 : newFromStopControlPoint.cpIndex + 1 + try { + // Check things are defined for flow + clonedPatternSegments = await insertSegments( + clonedPatternSegments, + insertionPoint, + movedFromEnd, + movedFromStart, + movedStopControlPoint, + movedToEnd, + movedToStart, + newFromStopControlPoint, + newToStopControlPoint, + previousFromStopControlPoint, + previousToStopControlPoint + ) + } catch (err) { + console.log(err) + // TODO: i18n this. + dispatch(setErrorMessage({message: `Could not rearrange stops in pattern: ${err}`})) + return + } + + // 5. Insert the stop into the new position and then convert to array + clonedControlPoints.splice(insertionPoint, 0, movedStopControlPoint) + // $FlowFixMe + if (movedForward) clonedControlPoints[movedStopControlPoint.cpIndex] = null + // $FlowFixMe + else clonedControlPoints[movedStopControlPoint.cpIndex + 1] = null // +1 to account for the spliced element added to the array. + + // 6. Remove any null entries from our array. + clonedControlPoints = clonedControlPoints.filter(el => !!el) + clonedPatternSegments = clonedPatternSegments.filter(el => !!el) // Cast type to allow filtering by flow. + + // 7. Update the control points with new shape_dist_traveled values + // $FlowFixMe + updateShapeDistTraveled(clonedControlPoints, clonedPatternSegments, newPatternStops) + + // 8. Update the pattern geometry and pattern stops to reflect changes. + dispatch(updatePatternGeometry({ + controlPoints: clonedControlPoints, + patternSegments: clonedPatternSegments + })) + + // Update pattern stops with cloned copy of cards (from the reorder). NOTE: stop resequencing + // is handled by updatePatternStops. + dispatch(updatePatternStops(oldPattern, newPatternStops)) + return dispatch(saveActiveGtfsEntity('trippattern')) + } +} diff --git a/lib/editor/components/EditorInput.js b/lib/editor/components/EditorInput.js index 33cbd8abc..b72248c54 100644 --- a/lib/editor/components/EditorInput.js +++ b/lib/editor/components/EditorInput.js @@ -15,7 +15,7 @@ import {doesNotExist} from '../util/validation' import TimezoneSelect from '../../common/components/TimezoneSelect' import LanguageSelect from '../../common/components/LanguageSelect' import {getComponentMessages} from '../../common/util/config' -import toSentenceCase from '../../common/util/to-sentence-case' +import toSentenceCase from '../../common/util/text' import type {Entity, Feed, GtfsSpecField, GtfsAgency, GtfsStop} from '../../types' import type {EditorTables} from '../../types/reducers' diff --git a/lib/editor/components/ExceptionDate.js b/lib/editor/components/ExceptionDate.js index 9d1c1e79a..e40052ab6 100644 --- a/lib/editor/components/ExceptionDate.js +++ b/lib/editor/components/ExceptionDate.js @@ -9,8 +9,11 @@ import {toast} from 'react-toastify' import {updateActiveGtfsEntity} from '../actions/active' import type {ScheduleException} from '../../types' +import type {EditorValidationIssue} from '../util/validation' +import { getComponentMessages } from '../../common/util/config' import {sortDates} from './ExceptionDateRange' +import ExceptionValidationErrorsList from './ExceptionValidationErrorsList' type Props = { activeComponent: string, @@ -18,6 +21,7 @@ type Props = { date: number | string, index: number, updateActiveGtfsEntity: typeof updateActiveGtfsEntity, + validationErrors: Array, validationState: string } @@ -32,6 +36,8 @@ export const inputStyleProps = { } export default class ExceptionDate extends Component { + messages = getComponentMessages('ExceptionDate') + _addRange = () => { const {activeComponent, activeEntity, index, updateActiveGtfsEntity} = this.props const dates = [...activeEntity.dates] @@ -61,7 +67,7 @@ export default class ExceptionDate extends Component { if (dates.some(date => date === newDate && date !== 'Invalid date')) { dates.splice(index, 1) toast.warn( - `ⓘ Date has been removed. Date entered is already included in an existing range or single date!`, + this.messages('dateRemoved'), { position: 'top-right', autoClose: 4000, @@ -93,7 +99,7 @@ export default class ExceptionDate extends Component { } render () { - const {date, index, validationState} = this.props + const {date, index, validationErrors, validationState} = this.props const dateTimeProps = { mode: 'date', dateTime: date ? +moment(date) : undefined, @@ -103,7 +109,7 @@ export default class ExceptionDate extends Component { } if (!date) { - dateTimeProps.defaultText = 'Select date' + dateTimeProps.defaultText = this.messages('selectDate') } return ( { padding: '4' }} > - Add range + {this.messages('addRange')} {validationState === 'error' && date ? - {moment(date).format('MM/DD/YYYY')} appears in another schedule exception. Please choose another date. + : null } diff --git a/lib/editor/components/ExceptionDateRange.js b/lib/editor/components/ExceptionDateRange.js index e1f9086c9..f3c627426 100644 --- a/lib/editor/components/ExceptionDateRange.js +++ b/lib/editor/components/ExceptionDateRange.js @@ -6,10 +6,13 @@ import DateTimeField from 'react-bootstrap-datetimepicker' import moment from 'moment' import {updateActiveGtfsEntity} from '../actions/active' +import { getComponentMessages } from '../../common/util/config' import {modifyRangeOfDates, updateDates} from '../../common/util/exceptions' import type {ExceptionDate, ScheduleException, ScheduleExceptionDateRange} from '../../types' +import type {EditorValidationIssue} from '../util/validation' import {inputStyleProps} from './ExceptionDate' +import ExceptionValidationErrorsList from './ExceptionValidationErrorsList' type Props = { activeComponent: string, @@ -17,6 +20,7 @@ type Props = { index: number, range: ScheduleExceptionDateRange, updateActiveGtfsEntity: typeof updateActiveGtfsEntity, + validationErrors: Array, validationState: string } @@ -27,6 +31,8 @@ type State = { export const sortDates = (dates: Array): void => { dates.sort((a, b) => moment(a).diff(moment(b))) } export default class ExceptionDateRange extends Component { + messages = getComponentMessages('ExceptionDateRange') + state = { forceErrorState: false } @@ -99,7 +105,7 @@ export default class ExceptionDateRange extends Component { } render () { - const {index, range, validationState} = this.props + const {index, range, validationErrors, validationState} = this.props const endMoment = range.endDate ? moment(range.endDate) : moment(range.startDate).add(1, 'days') const startMoment = range.startDate ? moment(range.startDate) : undefined @@ -169,13 +175,13 @@ export default class ExceptionDateRange extends Component { }} title={} > - Delete range - Delete end date + {this.messages('deleteRange')} + {this.messages('deleteEndDate')} {validationState === 'error' && startMoment && endMoment ? - One or more dates between {startMoment.format('MM/DD/YYYY')} and {endMoment.format('MM/DD/YYYY')} appears in another schedule exception. Please choose another date. + : null } diff --git a/lib/editor/components/ExceptionValidationErrorsList.js b/lib/editor/components/ExceptionValidationErrorsList.js new file mode 100644 index 000000000..97173a122 --- /dev/null +++ b/lib/editor/components/ExceptionValidationErrorsList.js @@ -0,0 +1,34 @@ +// @flow + +import React from 'react' + +import { getComponentMessages } from '../../common/util/config' +import type { EditorValidationIssue } from '../util/validation' + +const VALIDATION_ERROR_LIMIT = 3 + +const ExceptionValidationErrorsList = ({validationErrors}: {validationErrors: Array}) => { + const messages = getComponentMessages('ExceptionValidationErrorsList') + const excessValidationErrors = validationErrors.length - VALIDATION_ERROR_LIMIT + + return ( +
    + {validationErrors.map((err, index) => { + if (index < VALIDATION_ERROR_LIMIT) { + return ( +
  • + {err.reason} +
  • + ) + } + })} + {excessValidationErrors > 0 && ( +
  • + {messages('andOtherErrors').replace('%errors%', excessValidationErrors.toString())} +
  • + )} +
+ ) +} + +export default ExceptionValidationErrorsList diff --git a/lib/editor/components/ScheduleExceptionForm.js b/lib/editor/components/ScheduleExceptionForm.js index bff08c2b5..d15a615fa 100644 --- a/lib/editor/components/ScheduleExceptionForm.js +++ b/lib/editor/components/ScheduleExceptionForm.js @@ -13,7 +13,7 @@ import Select from 'react-select' import FlipMove from 'react-flip-move' import {updateActiveGtfsEntity} from '../actions/active' -import toSentenceCase from '../../common/util/to-sentence-case' +import toSentenceCase from '../../common/util/text' import {getRangesForDates} from '../../common/util/exceptions' import {EXCEPTION_EXEMPLARS} from '../util' import {getTableById} from '../util/gtfs' diff --git a/lib/editor/components/map/pattern-debug-lines.js b/lib/editor/components/map/pattern-debug-lines.js index aa17ccce2..57174603a 100644 --- a/lib/editor/components/map/pattern-debug-lines.js +++ b/lib/editor/components/map/pattern-debug-lines.js @@ -7,7 +7,6 @@ import lineString from 'turf-linestring' import {PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} from '../../constants' import {isValidStopControlPoint} from '../../util/map' - import type {ControlPoint, GtfsStop, Pattern} from '../../../types' import type {EditSettingsState} from '../../../types/reducers' @@ -20,6 +19,13 @@ type Props = { stops: Array } +export const getControlPointsForStops = (controlPoints: Array) => controlPoints + // Add control point indexes to determine if active segment is adjacent + // i.e., whether the line should be rendered. + .map((cp, index) => ({...cp, cpIndex: index})) + // Filter out the user-added anchors + .filter(isValidStopControlPoint) + /** * This react-leaflet component draws connecting lines between a pattern * geometry's anchor points (that are associated with stops) and their @@ -37,12 +43,7 @@ export default class PatternDebugLines extends PureComponent { if (!activePattern || !controlPoints || !stops) return null return (
- {controlPoints - // Add control point indexes to determine if activesegment is adjacent - // i.e., whether the line should be rendered. - .map((cp, index) => ({...cp, cpIndex: index})) - // Filter out the user-added anchors - .filter(isValidStopControlPoint) + {getControlPointsForStops(controlPoints) // The remaining number should match the number of stops .map((cp, index) => { const {cpIndex, point, stopId} = cp @@ -52,8 +53,8 @@ export default class PatternDebugLines extends PureComponent { return null } const patternStopIsActive = patternStop.index === index - // Do not render if some other pattern stop is active - if (typeof patternStop.index === 'number' && !patternStopIsActive) { + // Do not render if some other pattern stop is active or if we do not have point info (to make flow happy). + if ((typeof patternStop.index === 'number' && !patternStopIsActive) || (!point || !point.geometry || !point.geometry.coordinates)) { return null } const {coordinates: cpCoord} = point.geometry diff --git a/lib/editor/components/pattern/EditSettings.js b/lib/editor/components/pattern/EditSettings.js index d353495fe..c398c45fb 100644 --- a/lib/editor/components/pattern/EditSettings.js +++ b/lib/editor/components/pattern/EditSettings.js @@ -6,7 +6,7 @@ import Rcslider from 'rc-slider' import {updateEditSetting} from '../../actions/active' import {CLICK_OPTIONS} from '../../util' -import toSentenceCase from '../../../common/util/to-sentence-case' +import toSentenceCase from '../../../common/util/text' import type {EditSettingsState} from '../../../types/reducers' type Props = { diff --git a/lib/editor/components/pattern/EditShapePanel.js b/lib/editor/components/pattern/EditShapePanel.js index 9f72213de..2bf24d87b 100644 --- a/lib/editor/components/pattern/EditShapePanel.js +++ b/lib/editor/components/pattern/EditShapePanel.js @@ -150,6 +150,7 @@ export default class EditShapePanel extends Component { _getPatternStopsWithShapeIssues = () => { const {controlPoints, stops} = this.props return controlPoints + // $FlowFixMe: can't tell flow all elements are defined in the map. .filter(isValidStopControlPoint) .map((controlPoint, index) => { const {point, stopId} = controlPoint diff --git a/lib/editor/components/pattern/PatternStopContainer.js b/lib/editor/components/pattern/PatternStopContainer.js index 673099ca6..f414780a6 100644 --- a/lib/editor/components/pattern/PatternStopContainer.js +++ b/lib/editor/components/pattern/PatternStopContainer.js @@ -26,11 +26,13 @@ type Props = { status: any, stops: Array, updateActiveGtfsEntity: typeof activeActions.updateActiveGtfsEntity, - updatePatternStops: typeof tripPatternActions.updatePatternStops + updatePatternStops: typeof tripPatternActions.updatePatternStops, + updateShapesAfterPatternReorder: typeof stopStrategiesActions.updateShapesAfterPatternReorder } type State = { - cards: Array + cards: Array, + updatedPatternStopSequence: number } const cardTarget = { @@ -62,29 +64,11 @@ class PatternStopContainer extends Component { dropCard = () => { const { activePattern, - saveActiveGtfsEntity, - updatePatternStops + updateShapesAfterPatternReorder } = this.props - // FIXME: Move around control points based on pattern stop reorder? Simply - // changing the stop IDs is not sufficient (the shape dist traveled probably) - // out to change too. However, this may not be necessary. - // let stopIndex = 0 - // updatePatternGeometry({ - // // Reverse control points - // controlPoints: clone(controlPoints).map((cp, i) => { - // if (cp.pointType === POINT_TYPE.STOP) { - // // Update stopId based on new pattern stop order - // cp.stopId = patternStops[stopIndex++].stopId - // } - // return cp - // }), - // // Reverse order of segments and each segment's coordinate list. - // patternSegments - // }) - // Update pattern stops with cloned copy of cards. NOTE: stop resequencing - // is handled by updatePatternStops. - updatePatternStops(activePattern, [...this.state.cards]) - saveActiveGtfsEntity('trippattern') + // Update the modified pattern segments and update the control points with appropriate shape_dist_traveled values. + // This method also saves the trip pattern to preserve changes + updateShapesAfterPatternReorder(activePattern, [...this.state.cards], this.state.updatedPatternStopSequence) } moveCard = (id, atIndex) => { @@ -95,7 +79,8 @@ class PatternStopContainer extends Component { [index, 1], [atIndex, 0, card] ] - } + }, + updatedPatternStopSequence: {$set: card.stopSequence} })) } diff --git a/lib/editor/components/pattern/PatternStopsPanel.js b/lib/editor/components/pattern/PatternStopsPanel.js index 1192ecda8..f9d9ca52f 100644 --- a/lib/editor/components/pattern/PatternStopsPanel.js +++ b/lib/editor/components/pattern/PatternStopsPanel.js @@ -8,15 +8,15 @@ import * as activeActions from '../../actions/active' import * as mapActions from '../../actions/map' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' -import AddPatternStopDropdown from './AddPatternStopDropdown' -import NormalizeStopTimesModal from './NormalizeStopTimesModal' -import PatternStopContainer from './PatternStopContainer' import VirtualizedEntitySelect from '../VirtualizedEntitySelect' import {getEntityBounds, getEntityName} from '../../util/gtfs' - import type {Pattern, GtfsStop, Feed, ControlPoint, Coordinates} from '../../../types' import type {EditorStatus, EditSettingsUndoState, MapState} from '../../../types/reducers' +import PatternStopContainer from './PatternStopContainer' +import NormalizeStopTimesModal from './NormalizeStopTimesModal' +import AddPatternStopDropdown from './AddPatternStopDropdown' + type Props = { activePattern: Pattern, addStopToPattern: typeof stopStrategiesActions.addStopToPattern, @@ -38,7 +38,8 @@ type Props = { updateEditSetting: typeof activeActions.updateEditSetting, updateMapSetting: typeof mapActions.updateMapSetting, updatePatternGeometry: typeof mapActions.updatePatternGeometry, - updatePatternStops: typeof tripPatternActions.updatePatternStops + updatePatternStops: typeof tripPatternActions.updatePatternStops, + updateShapesAfterPatternReorder: typeof stopStrategiesActions.updateShapesAfterPatternReorder } type State = { @@ -98,7 +99,8 @@ export default class PatternStopsPanel extends Component { status, stops, updateActiveGtfsEntity, - updatePatternStops + updatePatternStops, + updateShapesAfterPatternReorder } = this.props const {addStops} = editSettings.present const {patternStopCandidate} = this.state @@ -167,6 +169,7 @@ export default class PatternStopsPanel extends Component { patternSegments={patternSegments} updatePatternStops={updatePatternStops} updatePatternGeometry={updatePatternGeometry} + updateShapesAfterPatternReorder={updateShapesAfterPatternReorder} status={status} updateActiveGtfsEntity={updateActiveGtfsEntity} saveActiveGtfsEntity={saveActiveGtfsEntity} diff --git a/lib/editor/components/pattern/TripPatternList.js b/lib/editor/components/pattern/TripPatternList.js index 52285ca90..f6bea4456 100644 --- a/lib/editor/components/pattern/TripPatternList.js +++ b/lib/editor/components/pattern/TripPatternList.js @@ -11,9 +11,6 @@ import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' import Loading from '../../../common/components/Loading' import * as statusActions from '../../../manager/actions/status' -import TripPatternViewer from './TripPatternViewer' -import TripPatternListControls from './TripPatternListControls' - import type {Props as ContainerProps} from '../../containers/ActiveTripPatternList' import type { ControlPoint, @@ -29,6 +26,9 @@ import type { MapState } from '../../../types/reducers' +import TripPatternListControls from './TripPatternListControls' +import TripPatternViewer from './TripPatternViewer' + export type Props = ContainerProps & { activeEntity: GtfsRoute, activePattern: Pattern, @@ -63,7 +63,8 @@ export type Props = ContainerProps & { updateEditSetting: typeof activeActions.updateEditSetting, updateMapSetting: typeof mapActions.updateMapSetting, updatePatternGeometry: typeof mapActions.updatePatternGeometry, - updatePatternStops: typeof tripPatternActions.updatePatternStops + updatePatternStops: typeof tripPatternActions.updatePatternStops, + updateShapesAfterPatternReorder: typeof stopStrategiesActions.updateShapesAfterPatternReorder } export default class TripPatternList extends Component { diff --git a/lib/editor/components/timetable/TimetableEditor.js b/lib/editor/components/timetable/TimetableEditor.js index a9498f899..6b81f6ce8 100644 --- a/lib/editor/components/timetable/TimetableEditor.js +++ b/lib/editor/components/timetable/TimetableEditor.js @@ -151,7 +151,7 @@ export default class TimetableEditor extends Component { cloneSelectedTrips = () => this.duplicateRows(this._getSelectedRowIndexes()) - constructNewRow = (toClone: ?Trip = null, tripSeriesStartTime: ?number): ?Trip => { + constructNewRow = (toClone: ?Trip = null, tripSeriesStartTime: ?number, autoTripId: ?string): ?Trip => { const {activePatternId, route} = this.props const activePattern = route && route.tripPatterns ? route.tripPatterns.find(p => p.id === activePatternId) @@ -209,6 +209,7 @@ export default class TimetableEditor extends Component { objectPath.set(newRow, `stopTimes.${i}.continuousPickup`, stop.continuousPickup) objectPath.set(newRow, `stopTimes.${i}.continuousDropOff`, stop.continuousDropOff) objectPath.set(newRow, `stopTimes.${i}.timepoint`, stop.timepoint || 0) + objectPath.set(newRow, `stopTimes.${i}.stopHeadsign`, stop.stopHeadsign) objectPath.set(newRow, `stopTimes.${i}.shapeDistTraveled`, stop.shapeDistTraveled) // Use pattern stop index to set stop sequence. Stop sequences should all // be zero-based and incrementing in the editor, but in the case that @@ -227,7 +228,7 @@ export default class TimetableEditor extends Component { } // IMPORTANT: set id to NEW_ID objectPath.set(newRow, 'id', ENTITY.NEW_ID) - objectPath.set(newRow, 'tripId', null) + objectPath.set(newRow, 'tripId', autoTripId || null) objectPath.set(newRow, 'useFrequency', activePattern.useFrequency) if (activePattern.useFrequency) { // If a frequency-based trip, never use exact times. NOTE: there is no diff --git a/lib/editor/components/timetable/TimetableHeader.js b/lib/editor/components/timetable/TimetableHeader.js index d94131f1f..b07cfdec0 100644 --- a/lib/editor/components/timetable/TimetableHeader.js +++ b/lib/editor/components/timetable/TimetableHeader.js @@ -148,6 +148,7 @@ export default class TimetableHeader extends Component { props: { children: , 'data-test-id': 'create-trip-series-button', + disabled: activePattern && activePattern.useFrequency, onClick: showTripSeriesModal } }, { diff --git a/lib/editor/components/timetable/TripSeriesModal.js b/lib/editor/components/timetable/TripSeriesModal.js index 15672205f..6d1d2a555 100644 --- a/lib/editor/components/timetable/TripSeriesModal.js +++ b/lib/editor/components/timetable/TripSeriesModal.js @@ -1,7 +1,7 @@ // @flow // $FlowFixMe: Flow doesn't know about useState: https://stackoverflow.com/questions/53105954/cannot-import-usestate-because-there-is-no-usestate-export-in-react-flow-with import React, {useCallback, useState} from 'react' -import {Button, Modal} from 'react-bootstrap' +import {Button, Checkbox, FormControl, Modal} from 'react-bootstrap' import * as tripActions from '../../actions/trip' import {getComponentMessages} from '../../../common/util/config' @@ -10,7 +10,7 @@ import type {Trip} from '../../../types' type Props = { addNewTrip: typeof tripActions.addNewTrip, - constructNewRow: (trip: ?Trip, tripSeriesStartTime: number) => ?Trip, + constructNewRow: (trip: ?Trip, tripSeriesStartTime: ?number, autoTripId: ?string) => ?Trip, onClose: () => void, show: boolean } @@ -19,22 +19,35 @@ const TripSeriesModal = (props: Props) => { const [startTime, setStartTime] = useState(null) const [headway, setHeadway] = useState(null) const [endTime, setEndTime] = useState(null) + const [useAutoTripIds, setUseAutoTripIds] = useState(false) + const [autoTripIdStart, setAutoTripIdStart] = useState(0) + const [autoTripIdIncrement, setAutoTripIdIncrement] = useState(1) + const [autoTripIdPrefix, setAutoTripIdPrefix] = useState('') const messages = getComponentMessages('TripSeriesModal') + const handleIncrementStartUpdate = (evt: SyntheticInputEvent) => setAutoTripIdStart(+evt.target.value) + const handleTripPrefixUpdate = (evt: SyntheticInputEvent) => setAutoTripIdPrefix(evt.target.value) + const handleTripIncrementUpdate = (evt: SyntheticInputEvent) => setAutoTripIdIncrement(+evt.target.value) + const handleCheckBox = () => setUseAutoTripIds(!useAutoTripIds) + const onClickGenerate = useCallback(() => { const {addNewTrip, constructNewRow, onClose} = props // Check state variables to make flow happy if (startTime === null || endTime === null || headway === null) return const adjustedEndTime = startTime < endTime ? endTime : endTime + 24 * 60 * 60 + + let tripId = autoTripIdStart for (let time = startTime; time <= adjustedEndTime; time += headway) { - addNewTrip(constructNewRow(null, time)) + // If we're upating the trip IDs automatically, increment the trip ID: + addNewTrip(constructNewRow(null, time, useAutoTripIds ? `${autoTripIdPrefix}-${tripId}` : null)) + if (useAutoTripIds) tripId += autoTripIdIncrement } setStartTime(null) setEndTime(null) setHeadway(null) onClose() - }, [endTime, startTime, headway]) + }, [endTime, startTime, headway, autoTripIdStart, autoTripIdIncrement, autoTripIdPrefix]) const {Body, Footer, Header, Title} = Modal const {onClose, show} = props @@ -44,9 +57,48 @@ const TripSeriesModal = (props: Props) => {
{messages('createTripSeriesQuestion')}

{messages('createTripSeriesBody')}

- {messages('startTime')}
- {messages('headway')}
- {messages('endTime')}
+
+
{messages('startTime')}
+
{messages('headway')}
+
{messages('endTime')}
+
+
+ {messages('automaticallyUpdateTripIds')} +
+ {useAutoTripIds && + <> + + - + + {messages('incrementBy')} + + + } +
+
+