diff --git a/.github/issue_template.md b/.github/issue_template.md index 52aedb349..6fd56471c 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 responded to in a timely manner. [Conveyal](https://www.conveyal.com) is unable to provide technical support for custom deployments of this software unless your company has 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. [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._ ## Observed behavior (please include a screenshot if possible) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4900eb602..3e0b2267b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,6 @@ - [ ] All tests and CI builds passing - [ ] The description lists all relevant PRs included in this release _(remove this if not merging to master)_ - [ ] e2e tests are all passing _(remove this if not merging to master)_ -- [ ] Code coverage does not significantly worsen (ideally it improves) _(remove this if not merging to master)_ ### Description diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml new file mode 100644 index 000000000..258b9e2f4 --- /dev/null +++ b/.github/workflows/node-ci.yml @@ -0,0 +1,107 @@ +name: Node.js CI + +on: [push, pull_request] + +jobs: + test-build-release: + + runs-on: ubuntu-latest + # Add postgres for end-to-end + services: + postgres: + image: postgres:10.8 + # Set postgres env variables according to test env.yml config + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + # install python 3.x in order to have mkdocs properly installed + - uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install mkdocs + run: | + pip install mkdocs + mkdocs --version + - name: Use Node.js 12.x + uses: actions/setup-node@v1 + with: + node-version: 12.x + - name: Install npm/yarn packages using cache + uses: bahmutov/npm-install@v1 + # Inject slug vars, so that we can reference $GITHUB_HEAD_REF_SLUG for branch name + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v3.x + - name: Check if End-to-end should run + run: ./scripts/check-if-e2e-should-run-on-ci.sh + - name: Lint code + run: yarn lint + - name: Lint messages + run: yarn lint-messages + - name: Run flow check + run: yarn flow + - name: Run tests + run: yarn test-client + - name: Build with minification + run: yarn run build -- --minify + - name: Build docs + run: mkdocs build + - name: Start MongoDB + if: env.SHOULD_RUN_E2E == 'true' + uses: supercharge/mongodb-github-action@1.3.0 + with: + mongodb-version: 4.2 + - name: Add aws credentials for datatools-server + if: env.SHOULD_RUN_E2E == 'true' + run: mkdir ~/.aws && printf '%s\n' '[default]' 'aws_access_key_id=${AWS_ACCESS_KEY_ID}' 'aws_secret_access_key=${AWS_SECRET_ACCESS_KEY}' 'region=${AWS_REGION}' > ~/.aws/config + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Install otp-runner + if: env.SHOULD_RUN_E2E == 'true' + run: yarn global add https://github.com/ibi-group/otp-runner.git + - name: Run e2e tests + if: env.SHOULD_RUN_E2E == 'true' + run: yarn test-end-to-end + env: + AUTH0_API_CLIENT: ${{ secrets.AUTH0_API_CLIENT }} + AUTH0_API_SECRET: ${{ secrets.AUTH0_API_SECRET }} + AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} + AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }} + AUTH0_SECRET: ${{ secrets.AUTH0_SECRET }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + E2E_AUTH0_PASSWORD: ${{ secrets.E2E_AUTH0_PASSWORD }} + E2E_AUTH0_USERNAME: ${{ secrets.E2E_AUTH0_USERNAME }} + GRAPH_HOPPER_KEY: ${{ secrets.GRAPH_HOPPER_KEY }} + GTFS_DATABASE_PASSWORD: ${{ secrets.GTFS_DATABASE_PASSWORD }} + GTFS_DATABASE_URL: ${{ secrets.GTFS_DATABASE_URL }} + GTFS_DATABASE_USER: ${{ secrets.GTFS_DATABASE_USER }} + LOGS_S3_BUCKET: ${{ secrets.LOGS_S3_BUCKET }} + MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }} + MONGO_DB_NAME: ${{ secrets.MONGO_DB_NAME }} + MS_TEAMS_WEBHOOK_URL: ${{ secrets.MS_TEAMS_WEBHOOK_URL }} + OSM_VEX: ${{ secrets.OSM_VEX }} + RUN_E2E: "true" + S3_BUCKET: ${{ secrets.S3_BUCKET }} + SPARKPOST_EMAIL: ${{ secrets.SPARKPOST_EMAIL }} + SPARKPOST_KEY: ${{ secrets.SPARKPOST_KEY }} + TRANSITFEEDS_KEY: ${{ secrets.TRANSITFEEDS_KEY }} + # At this point, the build is successful. + - name: Semantic Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: yarn semantic-release diff --git a/.gitignore b/.gitignore index 57afa319e..9706b9000 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ env.yml-original .env !configurations/test/env.yml scripts/*client.json + +# Vs code settings +.vscode/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 000000000..31354ec13 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..c37466e2b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index df71b1a1d..000000000 --- a/.travis.yml +++ /dev/null @@ -1,56 +0,0 @@ -# although this is a node.js project, specify the use of Java in order for OTP -# and datatools-server to run as expected on Java 8 specifically. Although not -# confirmed in any way, another thought with using a Java-specific image is that -# some things may be more optimized for Java thus making for more expedient and -# reliable execution of Java code. -dist: trusty # jdk 8 not available on xenial -language: java -java: - - oraclejdk8 -install: true -notifications: - email: false -services: - # needed for e2e tests to start datatools-server - - mongodb - - postgresql -addons: - postgresql: 9.6 -cache: - directories: - - $HOME/.cache/yarn - - $HOME/.cache/pip -before_install: - # install node 12 - - nvm install 12 - # install yarn - - curl -o- -L https://yarnpkg.com/install.sh | bash - - pip install --user mkdocs - - source ./scripts/check-if-e2e-should-run-on-travis.sh - # create database for e2e tests - - if [ "$SHOULD_RUN_E2E" = "true" ]; then psql -U postgres -c 'CREATE DATABASE catalogue;'; fi - # add aws credentials for datatools-server - - if [ "$SHOULD_RUN_E2E" = "true" ]; then mkdir ~/.aws && printf '%s\n' '[default]' 'aws_access_key_id=${AWS_ACCESS_KEY_ID}' 'aws_secret_access_key=${AWS_SECRET_ACCESS_KEY}' 'region=us-east-1' > ~/.aws/config; else mkdir ~/.aws && printf '%s\n' '[default]' 'aws_access_key_id=foo' 'aws_secret_access_key=bar' 'region=us-east-1' > ~/.aws/config; fi -script: - - yarn - - yarn run lint - - yarn run lint-messages - - yarn run flow - - yarn run cover-client - # upload coverage results from unit tests and then delete coverage reports to - # avoid uploading the same coverage results twice - - bash <(curl -s https://codecov.io/bash) -c -F unit_tests - - if [ "$SHOULD_RUN_E2E" = "true" ]; then yarn run cover-end-to-end; fi - # upload coverage results from e2e tests and then delete coverage reports to - # avoid uploading the same coverage results twice - - if [ "$SHOULD_RUN_E2E" = "true" ]; then bash <(curl -s https://codecov.io/bash) -c -F end_to_end_tests; fi - - yarn run build -- --minify - - mkdocs build - -# If sudo is disabled, CI runs on container based infrastructure (allows caching &c.) -sudo: false - -# Push results to codecov.io -after_success: - # only deploy the release to github as the package is not needed on npm - - yarn run semantic-release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f254d1d32 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# CHANGELOG +---------------------- + * Fix timetable previous stop time checks from checking text columns. + * Add feature allowing routing to avoid highways. + * Support Polish language and add initial translations. + * Fix bug displaying null continuous_pickup as '0'. + * Add route type selector with new extended GTFS route types. + * Fix bug to update continuous_pickup/dropoff values correctly. \ No newline at end of file diff --git a/__tests__/end-to-end.js b/__tests__/end-to-end.js index 71b0789b4..7b0280bdd 100644 --- a/__tests__/end-to-end.js +++ b/__tests__/end-to-end.js @@ -259,6 +259,20 @@ async function expectSelectorToNotContainHtml (selector: string, html: string) { expect(innerHTML).not.toContain(html) } +/** + * Checks that the expected feed version validity dates are displayed. + */ +async function expectFeedVersionValidityDates (startDate: string, endDate: string) { + await expectSelectorToContainHtml( + '[data-test-id="active-feed-version-validity-start"]', + startDate + ) + await expectSelectorToContainHtml( + '[data-test-id="active-feed-version-validity-end"]', + endDate + ) +} + /** * Create a new project. Assumes that this is called while the browser is on * the home page. @@ -301,6 +315,7 @@ async function deleteProject (projectId: string) { // verify deletion await goto(`http://localhost:9966/project/${projectId}`) await waitForSelector('.project-not-found') + await wait(5000, 'for previously rendered project markup to be removed') await expectSelectorToContainHtml('.project-not-found', projectId) log.info(`confirmed successful deletion of project with id ${projectId}`) } @@ -549,6 +564,17 @@ async function pickColor (containerSelector: string, color: string) { await clearAndType(`${containerSelector} input`, color) } +/** + * A helper method to choose a route type + * in the route editor (but not in the feed editor). + */ +async function pickRouteType (containerSelector: string, routeOptionId: string) { + await click(`${containerSelector} a`) + await waitForSelector(`${containerSelector} .dropdown-content`) + await waitForSelector(`[data-test-id="${routeOptionId}"]`) + await click(`[data-test-id="${routeOptionId}"] label`) +} + /** * A helper method to type in an autocomplete value and then select an option * from an react-select component. @@ -1085,13 +1111,10 @@ describe('end-to-end', () => { await uploadGtfs() // wait for main tab to show up with version validity info - await waitForSelector('[data-test-id="feed-version-validity"]') + await waitForSelector('[data-test-id="active-feed-version-validity-start"]') // verify feed was uploaded - await expectSelectorToContainHtml( - '[data-test-id="feed-version-validity"]', - 'Valid from Jan. 01, 2014 to Dec. 31, 2018' - ) + await expectFeedVersionValidityDates('Jan 1, 2014', 'Dec 31, 2018') }, defaultTestTimeout) // this test also sets the feed source as deployable @@ -1132,10 +1155,7 @@ describe('end-to-end', () => { await wait(2000, 'for feed source to update') // verify that feed was fetched and processed - await expectSelectorToContainHtml( - '[data-test-id="feed-version-validity"]', - 'Valid from Apr. 08, 2018 to Jun. 30, 2018' - ) + await expectFeedVersionValidityDates('Apr 8, 2018', 'Jun 30, 2018') }, defaultTestTimeout) if (doNonEssentialSteps) { @@ -1245,10 +1265,7 @@ describe('end-to-end', () => { await wait(2000, 'for data to refresh') await waitForSelector('#feed-source-viewer-tabs') // verify that the previous feed is now the displayed feed - await expectSelectorToContainHtml( - '[data-test-id="feed-version-validity"]', - 'Valid from Apr. 08, 2018 to Jun. 30, 2018' - ) + await expectFeedVersionValidityDates('Apr 8, 2018', 'Jun 30, 2018') }, defaultTestTimeout) } }) @@ -1556,11 +1573,6 @@ describe('end-to-end', () => { await waitForSelector('[data-test-id="route-route_id-input-container"]') // fill out form - // set status to approved - await page.select( - '[data-test-id="route-status-input-container"] select', - '2' - ) // set public to yes await page.select( @@ -1593,9 +1605,9 @@ describe('end-to-end', () => { ) // route type - await page.select( - '[data-test-id="route-route_type-input-container"] select', - '3' + await pickRouteType( + '[data-test-id="route-route_type-input-container"]', + 'route-type-option-3' ) // route color @@ -1622,6 +1634,13 @@ describe('end-to-end', () => { 'example.branding.test' ) + // Set status to approved so the route is exported to a snapshot. + // Do this last, otherwise the approved status will change back to in-progress. + await page.select( + '[data-test-id="route-status-input-container"] select', + '2' + ) + // save await click('[data-test-id="save-entity-button"]') await wait(2000, 'for save to happen') @@ -1648,6 +1667,13 @@ describe('end-to-end', () => { ' updated' ) + // Set status to approved so the route is exported to a snapshot. + // Do this last, otherwise the approved status will change back to in-progress. + await page.select( + '[data-test-id="route-status-input-container"] select', + '2' + ) + // save await click('[data-test-id="save-entity-button"]') await wait(2000, 'for save to happen') @@ -2338,10 +2364,14 @@ describe('end-to-end', () => { // add 1st stop await reactSelectOption('.pattern-stop-card', 'la', 1, true) + await wait(500, 'for 1st stop to be selected') + await click('[data-test-id="add-pattern-stop-button"]') await wait(2000, 'for 1st stop to save') // add 2nd stop await reactSelectOption('.pattern-stop-card', 'ru', 1, true) + await wait(500, 'for 2nd stop to be selected') + await click('[data-test-id="add-pattern-stop-button"]') await wait(2000, 'for auto-save to happen') // reload to make sure stuff was saved @@ -2653,13 +2683,10 @@ describe('end-to-end', () => { await click('#feed-source-viewer-tabs-tab-') // wait for main tab to show up with version validity info - await waitForSelector('[data-test-id="feed-version-validity"]') + await waitForSelector('[data-test-id="active-feed-version-validity-start"]') // verify that snapshot was made active version - await expectSelectorToContainHtml( - '[data-test-id="feed-version-validity"]', - 'Valid from May. 29, 2018 to May. 29, 2028' - ) + await expectFeedVersionValidityDates('May 29, 2018', 'May 29, 2028') }, defaultTestTimeout, 'should create snapshot') // TODO: download and validate gtfs?? @@ -2688,7 +2715,10 @@ describe('end-to-end', () => { '[data-test-id="deployment-router-id"]' ) // get rid of router id text and react tags - routerId = innerHTML.replace('Router ID: ', '') + // (remove any square brackets too) + routerId = innerHTML + .replace('Router ID: ', '') + .replace(/[[\]]/g, '') // confirm deployment await click('[data-test-id="confirm-deploy-server-button"]') @@ -2698,9 +2728,11 @@ describe('end-to-end', () => { }, defaultTestTimeout + 30000) // Add thirty seconds for deployment job makeEditorEntityTest('should be able to do a trip plan on otp', async () => { + await wait(15000, 'for OTP to pick up the newly-built graph') // hit the otp endpoint + const url = `${OTP_ROOT}${routerId}/plan?fromPlace=37.04532992924222%2C-122.07542181015015&toPlace=37.04899494106061%2C-122.07432746887208&time=00%3A32&date=2018-07-24&mode=TRANSIT%2CWALK&maxWalkDistance=804.672&arriveBy=false&wheelchair=false&locale=en` const response = await fetch( - `${OTP_ROOT}${routerId}/plan?fromPlace=37.04532992924222%2C-122.07542181015015&toPlace=37.04899494106061%2C-122.07432746887208&time=12%3A32am&date=07-24-2018&mode=TRANSIT%2CWALK&maxWalkDistance=804.672&arriveBy=false&wheelchair=false&locale=en`, + url, { headers: { 'Content-Type': 'application/json; charset=utf-8' diff --git a/__tests__/test-utils/mock-data/manager.js b/__tests__/test-utils/mock-data/manager.js index 5d282687c..5db2d031a 100644 --- a/__tests__/test-utils/mock-data/manager.js +++ b/__tests__/test-utils/mock-data/manager.js @@ -4,7 +4,6 @@ import clone from 'lodash/cloneDeep' import UserPermissions from '../../../lib/common/user/UserPermissions' import UserSubscriptions from '../../../lib/common/user/UserSubscriptions' - import type { Deployment, FeedVersion, @@ -55,12 +54,15 @@ export function makeMockDeployment ( osmExtractUrl: null, otpCommit: null, otpVersion: null, - projectId: project.id, + peliasCsvFiles: [], + peliasResetDb: null, + peliasUpdate: null, + pinnedfeedVersionIds: [], projectBounds: {east: 0, west: 0, north: 0, south: 0}, - r5: false, - r5Version: null, + projectId: project.id, routerId: null, skipOsmExtract: false, + tripPlannerVersion: 'OTP_1', user: null } } @@ -70,6 +72,9 @@ export const mockProject = { autoFetchFeeds: true, autoFetchHour: 0, autoFetchMinute: 0, + autoDeploy: false, + autoDeployTypes: [], + autoDeployWithCriticalErrors: false, bounds: null, buildConfig: { fares: null, @@ -83,10 +88,12 @@ export const mockProject = { feedSources: [], id: 'mock-project-id', lastUpdated: 1553236399556, + labels: [], name: 'mock-project', organizationId: null, otpServers: [], pinnedDeploymentId: null, + peliasWebhookUrl: null, routerConfig: { carDropoffTime: null, numItineraries: null, @@ -131,6 +138,7 @@ export const mockFeedWithVersion = { tripCount: 415 }, latestVersionId: 'mock-feed-version-id', + labelIds: [], name: 'test feed with a version', noteCount: 0, organizationId: null, @@ -141,7 +149,8 @@ export const mockFeedWithVersion = { snapshotVersion: null, transformRules: [], url: 'http://mdtrip.org/googletransit/AnnapolisTransit/google_transit.zip', - user: null + user: null, + versionCount: 1 } // a mock feed with no versions @@ -154,6 +163,7 @@ export const mockFeedWithoutVersion = { isPublic: false, lastFetched: null, name: 'test feed with no version', + labelIds: [], noteCount: 0, organizationId: null, projectId: mockProject.id, @@ -163,7 +173,8 @@ export const mockFeedWithoutVersion = { snapshotVersion: null, transformRules: [], url: null, - user: null + user: null, + versionCount: 0 } // a mock feedversion that has validation data @@ -256,6 +267,7 @@ export const mockFeedVersion = { uniqueIdentifier: 'ugez_nbyelwgcecgmjjabppvknj' }, feedSource: mockFeedWithVersion, + feedSourceId: mockFeedWithVersion.id, fileSize: 126865, fileTimestamp: 1533824462000, id: 'mock-feed-version-id', diff --git a/__tests__/test-utils/setup-e2e.js b/__tests__/test-utils/setup-e2e.js index e64107e2b..995892a19 100644 --- a/__tests__/test-utils/setup-e2e.js +++ b/__tests__/test-utils/setup-e2e.js @@ -20,7 +20,21 @@ const { } = require('./utils') const serverJarFilename = 'dt-latest-dev.jar' - +const otpJarMavenUrl = 'https://repo1.maven.org/maven2/org/opentripplanner/otp/1.4.0/otp-1.4.0-shaded.jar' +const otpJarForOtpRunner = '/opt/otp/otp-v1.4.0' +const ENV_YML_VARIABLES = [ + 'AUTH0_CLIENT_ID', + 'AUTH0_DOMAIN', + 'AUTH0_SECRET', + 'AUTH0_API_CLIENT', + 'AUTH0_API_SECRET', + 'GTFS_DATABASE_PASSWORD', + 'GTFS_DATABASE_USER', + 'GTFS_DATABASE_URL', + 'OSM_VEX', + 'SPARKPOST_KEY', + 'SPARKPOST_EMAIL' +] /** * download, configure and start an instance of datatools-server */ @@ -35,24 +49,17 @@ async function startBackendServer () { // make sure required environment variables are set try { requireEnvVars([ - 'AUTH0_CLIENT_ID', - 'AUTH0_DOMAIN', - 'AUTH0_SECRET', - 'AUTH0_API_CLIENT', - 'AUTH0_API_SECRET', - 'OSM_VEX', - 'SPARKPOST_EMAIL', - 'SPARKPOST_KEY', + ...ENV_YML_VARIABLES, 'S3_BUCKET', 'TRANSITFEEDS_KEY' ]) } catch (e) { - console.error(`At least one required env var is missin: ${e}`) + console.error(`At least one required env var is missing: ${e}`) throw e } const serverFolder = path.join( - process.env.TRAVIS_BUILD_DIR, + process.env.GITHUB_WORKSPACE, '..', 'datatools-server' ) @@ -109,16 +116,7 @@ async function startBackendServer () { results.readEnvTemplate, pick( process.env, - [ - 'AUTH0_CLIENT_ID', - 'AUTH0_DOMAIN', - 'AUTH0_SECRET', - 'AUTH0_API_CLIENT', - 'AUTH0_API_SECRET', - 'OSM_VEX', - 'SPARKPOST_KEY', - 'SPARKPOST_EMAIL' - ] + ENV_YML_VARIABLES ) ) ) @@ -194,13 +192,13 @@ async function startClientServer () { 'TRANSITFEEDS_KEY' ]) } catch (e) { - console.error(`At least one required env var is missin: ${e}`) + console.error(`At least one required env var is missing: ${e}`) throw e } // set the working directories for datatools-ui const datatoolsUiDir = path.join( - process.env.TRAVIS_BUILD_DIR, + process.env.GITHUB_WORKSPACE, '..', 'datatools-ui' ) @@ -337,12 +335,13 @@ async function startOtp () { console.log('downloading otp jar') // download otp - await downloadFile( - 'https://repo1.maven.org/maven2/org/opentripplanner/otp/1.4.0/otp-1.4.0-shaded.jar', - otpJarFilename - ) + await downloadFile(otpJarMavenUrl, otpJarFilename) console.log('starting otp') + // Ensure default folder for graphs exists. + // (OTP 1.4.0 autoscan() does a directory listing without checking directory existence.) + const otpBasePath = '/tmp/otp' + await fs.mkdirp(`${otpBasePath}/graphs`) // start otp try { @@ -353,6 +352,9 @@ async function startOtp () { '-jar', otpJarFilename, '--server', + '--autoScan', + '--basePath', + otpBasePath, '--insecure', '--router', 'default' @@ -404,6 +406,19 @@ async function verifySetupForLocalEnvironment () { } ] + // Make sure that certain e2e folders have permissions (assumes running on Linux/MacOS). + const desiredMode = 0o2777 + await fs.ensureDir('/tmp/otp', desiredMode) // For otp-runner manifest files + await fs.ensureDir('/tmp/otp/graphs', desiredMode) // For OTP graph + await fs.ensureDir('/var/log', desiredMode) // For otp-runner log + await fs.ensureDir('/opt/otp', desiredMode) // For OTP jar referenced by otp-runner + + // Download OTP jar into /opt/otp/ if not already present. + const otpJarExists = await fs.exists(otpJarForOtpRunner) + if (!otpJarExists) { + await downloadFile(otpJarMavenUrl, otpJarForOtpRunner) + } + await Promise.all( endpointChecks.map( endpoint => ( diff --git a/__tests__/test-utils/teardown-e2e.js b/__tests__/test-utils/teardown-e2e.js index 51e0c1f7a..232de18b7 100644 --- a/__tests__/test-utils/teardown-e2e.js +++ b/__tests__/test-utils/teardown-e2e.js @@ -19,10 +19,10 @@ const { const slackConfigured = process.env.SLACK_TOKEN && process.env.SLACK_CHANNEL const msTeamsConfigured = process.env.MS_TEAMS_WEBHOOK_URL const logsZipfile = 'logs.zip' -const repo = process.env.TRAVIS_BUILD_DIR - ? process.env.TRAVIS_BUILD_DIR.split(path.sep).pop() +const repo = process.env.GITHUB_WORKSPACE + ? process.env.GITHUB_WORKSPACE.split(path.sep).pop() : '' -const buildNum = process.env.TRAVIS_BUILD_NUMBER +const buildNum = process.env.GITHUB_RUN_ID const uploadedLogsFilename = `${repo}-build-${buildNum}-e2e-logs.zip` const {LOGS_S3_BUCKET} = process.env @@ -115,6 +115,10 @@ function shutdownOtp () { return killDetachedProcess('otp') } +function getBuildUrl () { + return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` +} + function makeUploadFailureHandler (handlerErrorMsg) { return (err) => { console.error(handlerErrorMsg) @@ -156,11 +160,11 @@ async function uploadToMicrosoftTeams () { ) const actions = [{ '@type': 'OpenUri', - name: `View Travis Build #${buildNum}`, + name: `View GitHub Action Build #${buildNum}`, targets: [ { os: 'default', - uri: process.env.TRAVIS_BUILD_WEB_URL + uri: getBuildUrl() } ] }] @@ -179,7 +183,7 @@ async function uploadToMicrosoftTeams () { } let fetchResponse - const commit = process.env.TRAVIS_COMMIT + const commit = process.env.GITHUB_SHA const baseRepoUrl = `https://github.com/ibi-group/datatools-${isUiRepo ? 'ui' : 'server'}` const commitUrl = `${baseRepoUrl}/commit/${commit}` try { @@ -192,7 +196,7 @@ async function uploadToMicrosoftTeams () { '@type': 'MessageCard', themeColor: '0072C6', title: `${repo} e2e test ${testResults.success ? 'passed. ✅' : 'failed. ❌'}`, - text: `📁 **branch:** ${process.env.TRAVIS_BRANCH}\n + text: `📁 **branch:** ${process.env.GITHUB_REF_SLUG}\n 📄 **commit:** [${commit.slice(0, 6)}](${commitUrl})\n 📊 **result:** ${testResults.numPassedTests} / ${testResults.numTotalTests} tests passed\n `, @@ -228,7 +232,7 @@ async function uploadToSlack () { file: fs.createReadStream(logsZipfile), filename: uploadedLogsFilename, filetype: 'zip', - initial_comment: `View build logs here: ${process.env.TRAVIS_BUILD_WEB_URL}` + initial_comment: `View build logs here: ${getBuildUrl()}` }) } catch (e) { console.error('failed to upload logs to slack!') diff --git a/__tests__/test-utils/utils.js b/__tests__/test-utils/utils.js index cbea341c4..e7ca9774f 100644 --- a/__tests__/test-utils/utils.js +++ b/__tests__/test-utils/utils.js @@ -8,7 +8,7 @@ const request = require('request') const collectingCoverage = process.env.COLLECT_COVERAGE const isCi = !!process.env.CI -const isUiRepo = process.env.TRAVIS_REPO_SLUG === 'ibi-group/datatools-ui' +const isUiRepo = process.env.GITHUB_REPOSITORY === 'ibi-group/datatools-ui' const testFolderPath = 'e2e-test-results' /** diff --git a/configurations/default/env.yml.tmp b/configurations/default/env.yml.tmp index 86b17dbc1..d05c954af 100644 --- a/configurations/default/env.yml.tmp +++ b/configurations/default/env.yml.tmp @@ -2,10 +2,12 @@ AUTH0_CLIENT_ID: your-auth0-client-id AUTH0_DOMAIN: your-auth0-domain BUGSNAG_KEY: optional-bugsnag-key MAPBOX_ACCESS_TOKEN: your-mapbox-access-token -MAPBOX_MAP_ID: mapbox.streets +MAPBOX_MAP_ID: mapbox/outdoors-v11 MAPBOX_ATTRIBUTION: © Mapbox © OpenStreetMap Improve this map SLACK_CHANNEL: optional-slack-channel SLACK_WEBHOOK: optional-slack-webhook GRAPH_HOPPER_KEY: your-graph-hopper-key +# Optional override to use a custom service instead of the graphhopper.com hosted service. +# GRAPH_HOPPER_URL: http://localhost:8989/ GOOGLE_ANALYTICS_TRACKING_ID: optional-ga-key # GRAPH_HOPPER_POINT_LIMIT: 10 # Defaults to 30 diff --git a/docs/dev/deployment.md b/docs/dev/deployment.md index 457c8ae5e..cde4c3ffb 100644 --- a/docs/dev/deployment.md +++ b/docs/dev/deployment.md @@ -4,8 +4,11 @@ The application consists of two repositories: a [Spark-powered Java backend](https://github.com/ibi-group/datatools-server) and a [Javascript frontend written with React and Redux](https://github.com/ibi-group/datatools-ui). -To install and deploy the application, you will need Java 8, Maven, Node/npm, -yarn, and [mastarm](https://github.com/conveyal/mastarm). +To install and deploy the application, you will need Java 8 and Maven for the +[datatools-server](https://github.com/ibi-group/datatools-server) +and Node (>= v10 required, >= v14 recommended), npm, yarn and +[mastarm](https://github.com/conveyal/mastarm) for the +[datatools-ui](https://github.com/ibi-group/datatools-ui). User authentication is done via [Auth0](http://auth0.com). You will need an Auth0 account and application to use the Data Manager. @@ -34,10 +37,12 @@ $ cp datatools-server/configurations/default/env.yml.tmp datatools-server/config You'll then need to supply Auth0 account information (see below) and API keys for various services used in the application. -The default `server.yml` (for `datatools-server`) and `settings.yml` (for -`datatools-ui`) should work out of the box, but you may want to specify -alternative settings files outside of these repositories. These can be specified -as a directory during `datatools-ui` build with mastarm: +The default +[server.yml](https://github.com/ibi-group/datatools-server/blob/dev/configurations/default/server.yml.tmp) for `datatools-server` and +[settings.yml](https://github.com/ibi-group/datatools-ui/blob/dev/configurations/default/settings.yml) for +`datatools-ui` should work out of the box, but you may want to specify +alternative settings files outside of these repositories. +These can be specified as a directory during `datatools-ui` build with mastarm: ```bash $ mastarm build --config /path/to/configurations/dir @@ -48,8 +53,7 @@ AND as individual file args for `datatools-server`: ```bash $ java -jar target/dt-v1.0.0.jar /path/to/env.yml /path/to/server.yml ``` -In `datatools-server:server.yml`, be sure to update the paths for where the -databases will be stored: +In `datatools-server:server.yml`, be sure to update the paths for the place where databases will be stored: ```yaml application: @@ -65,12 +69,13 @@ the application has been significantly tuned and optimized for PostgreSQL 9, so we highly recommend using PostgreSQL. Once PostgreSQL is installed and the service has been started, create the -database: +database for instance by using the CLI [createdb](https://www.postgresql.org/docs/9.1/app-createdb.html): ```bash $ createdb gtfs_storage_db ``` -Pass the URL of the database in the server's `env.yml` (and optionally add -additional connection variables): +Pass the URL of the database in the `datatools-server`'s +[env.yml](https://github.com/ibi-group/datatools-server/blob/dev/configurations/default/env.yml.tmp) +(and optionally add additional connection variables): ```yaml GTFS_DATABASE_URL: jdbc:postgresql://localhost/gtfs_storage_db # GTFS_DATABASE_USER: @@ -81,7 +86,8 @@ GTFS_DATABASE_URL: jdbc:postgresql://localhost/gtfs_storage_db Application data storage (i.e., where projects, feed sources, and feed versions are stored) is handled by MongoDB. There is no need to manually initialize a database in MongoDB (MongoDB will handle this automatically if you prefer). -Connection details for MongoDB are also set in the server's `env.yml`: +Connection details for MongoDB are also set in the `datatools-server`'s +[env.yml](https://github.com/ibi-group/datatools-server/blob/dev/configurations/default/env.yml.tmp): ```yaml MONGO_URI: # defaults to localhost:27017 (MongoDB default) if empty MONGO_DB_NAME: application_db @@ -176,32 +182,40 @@ function (user, context, callback) { ## Building and Running the Application -Install the Javascript dependencies using yarn: +Install the Javascript dependencies for `datatools-ui` using yarn: ```bash +$ cd datatools-ui $ yarn ``` -Build and deploy the frontend to s3 using npm script (which calls [mastarm](https://github.com/conveyal/mastarm)): +Build and deploy `datatools-ui` to s3 using npm script +(which calls [mastarm](https://github.com/conveyal/mastarm)): ```bash $ npm run deploy -- s3://$S3_BUCKET_NAME/dist ``` -Package the application using Maven: +Package `datatools-server` using Maven: ```bash +$ cd datatools-server $ mvn package ``` -Deploy the application with Java: +Deploy `datatools-server` with Java: ```bash $ java -jar target/dt-v1.0.0.jar /path/to/env.yml /path/to/server.yml ``` - -The application back-end should now be running at `http://localhost:9000` (or whatever port you specified in `server.yml`). The front-end assets are obtained from the `dist` folder relative the url specified in `server.yml` at `application.client_assets_url`. While running a development server of datatools-ui, these assets are delivered to the client using budo, so the links defined in the backend `server.yml` are only used in a production setting. +`Datatools-server` should now be running at `http://localhost:9000` +(or whatever port you specified in `server.yml`). +`Datatools-ui` assets are obtained from the `dist` +folder relative the url specified in `server.yml` at `application.client_assets_url`. +While running a development server of datatools-ui, +these assets are delivered to the client using budo, +so the links defined in the backend `server.yml` are only used in a production setting. ## Configuring Modules diff --git a/docs/user/deploying-feeds.md b/docs/user/deploying-feeds.md index 00148f6ec..17cb2dba3 100644 --- a/docs/user/deploying-feeds.md +++ b/docs/user/deploying-feeds.md @@ -22,6 +22,14 @@ To deploy or update GTFS feeds to OTP: 7. If you select `Custom` under `Build configuration` or `Router configuration`, enter the desired configuration settings. 8. Click the `Deploy` dropdown at the top of the main pane, then pick the server on which to perform the deployment. Existing deployments on that server will be discarded. +## Updating the Custom Places Index + +A GTFS feed's stops can be sent to a Custom Places Index, should one be set up. You'll need the secret Webhook URL of your Custom Places Index server. + +Only a pinned deployment's feed can be sent to a Custom Places Index. When opening a pinned deployment, a `Custom Geocoder Settings` pane appears below the `OTP Configuration` pane. In the text field, paste in the secret Webhook URL for your Custom Places Index server. Once it's entered, the `Update Custom Geocoder` checkbox will be clickable. If it is checked, your Custom Places Index will be updated when deploying the feed. + +The pane also has an option to upload Custom POI CSV files. These files contain special landmarks and coordinates which are prioritized when returning geocoder results. You can upload as many custom CSV files as you like. They will all be added to the Custom Places Index. + ## Watching deployments take place After click Deploy, you can watch the deployment progress from the right-hand panel: diff --git a/docs/user/managing-projects-feeds.md b/docs/user/managing-projects-feeds.md index 3e00ee3b0..34bd0d042 100644 --- a/docs/user/managing-projects-feeds.md +++ b/docs/user/managing-projects-feeds.md @@ -82,6 +82,7 @@ Once the Feed Transformations have all been configured, you can import a new GTF ![screenshot](../img/feed-transformation-summary.png) +A more in depth summary is available under the Transformation Results tab on the left sidebar. This panel will show you the modifications made to each file in the feed, along with how many rows were added, deleted, or modified. ## Viewing and Managing Feed Versions diff --git a/docs/user/setting-up-aws-servers.md b/docs/user/setting-up-aws-servers.md index 2f135ed4c..c36f83b2f 100644 --- a/docs/user/setting-up-aws-servers.md +++ b/docs/user/setting-up-aws-servers.md @@ -21,6 +21,7 @@ The OTP user interface is delivered using a plain HTTP file server that does not 1. From [AWS S3](https://console.aws.amazon.com/s3/home), click `Create Bucket`. Each deployment uses its own bucket. 2. Specify a name (write down the name for use in Data Tools later). 3. When specifying options, uncheck `Block All Public Access`. +Do not grant additional access from the bucket's `Permissions` tab. ### Create a CloudFront instance diff --git a/gtfs.yml b/gtfs.yml index 4170346d5..d23c2126e 100644 --- a/gtfs.yml +++ b/gtfs.yml @@ -23,6 +23,11 @@ inputType: LANGUAGE columnWidth: 12 helpContent: "The feed_lang field contains a IETF BCP 47 language code specifying the default language used for the text in this feed. This setting helps GTFS consumers choose capitalization rules and other language-specific settings for the feed. For an introduction to IETF BCP 47, please refer to http://www.rfc-editor.org/rfc/bcp/bcp47.txt and http://www.w3.org/International/articles/language-tags/." + - name: "default_lang" + required: false + inputType: LANGUAGE + columnWidth: 12 + helpContent: "Defines the language used when the data consumer doesn’t know the language of the rider. It's often defined as en, English." - name: "feed_start_date" required: false inputType: DATE @@ -67,6 +72,16 @@ inputType: TEXT columnWidth: 12 helpContent: "The feed publisher can specify a string here that indicates the current version of their GTFS feed. GTFS-consuming applications can display this value to help feed publishers determine whether the latest version of their feed has been incorporated." + - name: "feed_contact_email" + required: false + inputType: EMAIL + columnWidth: 12 + helpContent: "Email address for communication regarding the GTFS dataset and data publishing practices." + - name: "feed_contact_url" + required: false + inputType: URL + columnWidth: 12 + helpContent: "URL for contact information, a web-form, support desk, or other tools for communication regarding the GTFS dataset and data publishing practices." - id: agency name: agency.txt @@ -139,7 +154,7 @@ columnWidth: 6 helpContent: "The stop_code field contains short text or a number that uniquely identifies the stop for passengers. Stop codes are often used in phone-based transit information systems or printed on stop signage to make it easier for riders to get a stop schedule or real-time arrival information for a particular stop." - name: "stop_name" - required: true + required: false inputType: TEXT bulkEditEnabled: true columnWidth: 12 @@ -151,12 +166,12 @@ columnWidth: 12 helpContent: "The stop_desc field contains a description of a stop. Please provide useful, quality information. Do not simply duplicate the name of the stop." - name: "stop_lat" - required: true + required: false inputType: LATITUDE columnWidth: 6 helpContent: "The stop_lat field contains the latitude of a stop or station. The field value must be a valid WGS 84 latitude." - name: "stop_lon" - required: true + required: false inputType: LONGITUDE columnWidth: 6 helpContent: "The stop_lon field contains the longitude of a stop or station. The field value must be a valid WGS 84 longitude value from -180 to 180." @@ -180,8 +195,19 @@ text: Stop (0) - value: '1' text: Station (1) - columnWidth: 12 + - value: '2' + text: Entrance/Exit (2) + - value: '3' + text: Generic Node (3) + - value: '4' + text: Boarding Area (4) + columnWidth: 7 helpContent: "The location_type field identifies whether this stop ID represents a stop or station. If no location type is specified, or the location_type is blank, stop IDs are treated as stops. Stations may have different properties from stops when they are represented on a map or used in trip planning." + - name: "platform_code" + required: false + inputType: TEXT + columnWidth: 5 + helpContent: "Platform identifier for a platform stop (a stop belonging to a station). This should be just the platform identifier (eg. G or 3)." - name: "parent_station" required: false inputType: GTFS_STOP @@ -271,7 +297,7 @@ helpContent: 'Contains a description of a route. Please provide useful, quality information. Do not simply duplicate the name of the route. For example, "A trains operate between Inwood-207 St, Manhattan and Far Rockaway-Mott Avenue, Queens at all times. Also from about 6AM until about midnight, additional A trains operate between Inwood-207 St and Lefferts Boulevard (trains typically alternate between Lefferts Blvd and Far Rockaway)."' - name: route_type required: true - inputType: DROPDOWN + inputType: GTFS_ROUTE_TYPE bulkEditEnabled: true options: - value: 3 @@ -290,13 +316,193 @@ text: Gondola - value: 7 text: Funicular - columnWidth: 6 + - value: 11 + text: Trolleybus + - value: 12 + text: Monorail + - value: 100 + text: Railway Service + - value: 101 + text: High Speed Rail Service + - value: 102 + text: Long Distance Trains + - value: 103 + text: Inter Regional Rail Service + - value: 104 + text: Car Transport Rail Service + - value: 105 + text: Sleeper Rail Service + - value: 106 + text: Regional Rail Service + - value: 107 + text: Tourist Railway Service + - value: 108 + text: Rail Shuttle (Within Complex) + - value: 109 + text: Suburban Railway + - value: 110 + text: Replacement Rail Service + - value: 111 + text: Special Rail Service + - value: 112 + text: Lorry Transport Rail Service + - value: 113 + text: All Rail Services + - value: 114 + text: Cross-Country Rail Service + - value: 115 + text: Vehicle Transport Rail Service + - value: 116 + text: Rack and Pinion Railway + - value: 117 + text: Additional Rail Service + - value: 200 + text: Coach Service + - value: 201 + text: International Coach Service + - value: 202 + text: National Coach Service + - value: 203 + text: Shuttle Coach Service + - value: 204 + text: Regional Coach Service + - value: 205 + text: Special Coach Service + - value: 206 + text: Sightseeing Coach Service + - value: 207 + text: Tourist Coach Service + - value: 208 + text: Commuter Coach Service + - value: 209 + text: All Coach Services + - value: 400 + text: Urban Railway Service + - value: 401 + text: Metro Service + - value: 402 + text: Underground Service + - value: 403 + text: Urban Railway Service + - value: 404 + text: All Urban Railway Services + - value: 405 + text: Monorail + - value: 700 + text: Bus Service + - value: 701 + text: Regional Bus Service + - value: 702 + text: Express Bus Service + - value: 703 + text: Stopping Bus Service + - value: 704 + text: Local Bus Service + - value: 705 + text: Night Bus Service + - value: 706 + text: Post Bus Service + - value: 707 + text: Special Needs Bus + - value: 708 + text: Mobility Bus Service + - value: 709 + text: Mobility Bus for Registered Disabled + - value: 710 + text: Sightseeing Bus + - value: 711 + text: Shuttle Bus + - value: 712 + text: School Bus + - value: 713 + text: School and Public Service Bus + - value: 714 + text: Rail Replacement Bus Service + - value: 715 + text: Demand and Response Bus Service + - value: 716 + text: All Bus Services + - value: 900 + text: Tram Service + - value: 901 + text: City Tram Service + - value: 902 + text: Local Tram Service + - value: 903 + text: Regional Tram Service + - value: 904 + text: Sightseeing Tram Service + - value: 905 + text: Shuttle Tram Service + - value: 906 + text: All Tram Services + - value: 1000 + text: Water Transport Service + - value: 1100 + text: Air Service + - value: 1200 + text: Ferry Service + - value: 1300 + text: Aerial Lift Service + - value: 1400 + text: Funicular Service + - value: 1500 + text: Taxi Service + - value: 1501 + text: Communal Taxi Service + - value: 1502 + text: Water Taxi Service + - value: 1503 + text: Rail Taxi Service + - value: 1504 + text: Bike Taxi Service + - value: 1505 + text: Licensed Taxi Service + - value: 1506 + text: Private Hire Service Vehicle + - value: 1507 + text: All Taxi Services + - value: 1700 + text: Miscellaneous Service + - value: 1702 + text: Horse-drawn Carriage + columnWidth: 12 helpContent: The route_type field describes the type of transportation used on a route. Valid values for this field are... - name: route_sort_order required: false inputType: POSITIVE_INT columnWidth: 6 helpContent: The route_sort_order field can be used to order the routes in a way which is ideal for presentation to customers. It must be a non-negative integer. Routes with smaller route_sort_order values should be displayed before routes with larger route_sort_order values. + - name: continuous_pickup + required: false + inputType: DROPDOWN + bulkEditEnabled: true + options: + - value: 0 + text: Continuous stopping pickup (0) + - value: 1 + text: No continuous stopping pickup (1) + - value: 2 + text: Must phone an agency to arrange continuous stopping pickup (2) + - value: 3 + text: Must coordinate with a driver to arrange continuous stopping pickup (3) + columnWidth: 12 + helpContent: Indicates whether a rider can board the transit vehicle anywhere along the vehicle’s travel path. + - name: continuous_drop_off + required: false + inputType: DROPDOWN + bulkEditEnabled: true + options: + - value: 0 + text: Continuous stopping drop-off (0) + - value: 1 + text: No continuous stopping drop-off (1) + - value: 2 + text: Must phone an agency to arrange continuous stopping drop-off (2) + - value: 3 + text: Must coordinate with a driver to arrange continuous stopping drop-off (3) + columnWidth: 12 + helpContent: Indicates whether a rider can alight from the transit vehicle at any point along the vehicle’s travel path. - name: route_url required: false inputType: URL diff --git a/gtfsplus.yml b/gtfsplus.yml index db9cc4341..f92b0187d 100644 --- a/gtfsplus.yml +++ b/gtfsplus.yml @@ -340,3 +340,79 @@ inputType: TEXT maxLength: 35 helpContent: Public name of the fare zone, as it should appear on 511.org such as EastBay, WestBay, etc + +- id: route_attributes + name: route_attributes.txt + helpContent: This file contains route attributes such as geographical extent, times of service, and right-of-way type. + fields: + - name: route_id + required: true + inputType: GTFS_ROUTE_NOT_ADDED + columnWidth: 4 + helpContent: From GTFS routes.txt file. This is necessary to maintain relationship between routes in this file and routes in the standard GTFS routes.txt file. + - name: category + required: true + inputType: DROPDOWN + options: + - text: Interregional / Intercity + value: '1' + - text: Regional + value: '2' + - text: Local + value: '3' + - text: Community + value: '4' + - text: Connector + value: '5' + - name: subcategory + required: true + inputType: DROPDOWN + parent: 'category' + options: + - parentValue: '1' + text: Interregional / Intercity + value: '101' + - parentValue: '2' + text: Regional All Day + value: '201' + - parentValue: '2' + text: Regional Peak + value: '202' + - parentValue: '2' + text: Regional Owl + value: '203' + - parentValue: '3' + text: Local All Day + value: '301' + - parentValue: '3' + text: Local Peak + value: '302' + - parentValue: '3' + text: Local Owl + value: '303' + - parentValue: '4' + text: Community All Day + value: '401' + - parentValue: '4' + text: Community Special + value: '402' + - parentValue: '4' + text: Community On Demand + value: '403' + - parentValue: '5' + text: First / Last Mile Connector + value: '501' + - name: running_way + required: true + inputType: DROPDOWN + options: + - text: Fully / Primarily Dedicated + value: '1' + - text: "Limited Dedicated: Highway/HOV" + value: '2' + - text: "Limited Dedicated: Local Roadway" + value: '3' + - text: Shared, With Signal Priority + value: '4' + - text: Shared, No Priority + value: '5' diff --git a/i18n/english.yml b/i18n/english.yml index 5d8a0e331..665c7e940 100644 --- a/i18n/english.yml +++ b/i18n/english.yml @@ -22,6 +22,10 @@ components: placeholder: Additional information (optional) publishNewVersion: label: Publish snapshot as new feed version + confirmPublishWithUnapproved: + label: Confirm publish with unapproved routes + unapprovedRoutesHeader: "The following routes are not approved" + unapprovedRoutesDesc: "These routes will not be included in the output:" missingNameAlert: Must give snapshot a valid name! ok: OK title: Create a new snapshot @@ -69,6 +73,8 @@ components: stationTransfers: Sta. Transfers subwayAccessTime: Subway Access Time title: Build Config + clear: Clear + manageServers: Manage deployment servers osm: bounds: Custom Extract Bounds custom: Use Custom Extract Bounds @@ -138,8 +144,35 @@ components: name: Name title: Deployments DeploymentsPanel: + autoDeploy: + deployWithErrors: + checklabel: Deploy even if some feed versions have critical errors + help: > + If this is unchecked, an auto-deployment will halt if any of the feed + versions in the deployment have at least one critical error + title: Critical Errors Handling + help: > + A deployment will automatically be kicked off (assuming there are no + critical errors) whenever one of the above-defined events occurs. + label: Auto-deploy events + placeholder: Specify auto-deploy events + title: Auto-deployment + types: + ON_FEED_FETCH: A new version is fetched + ON_PROCESS_FEED: A new version is processed + config: + body: > + Deployments can use project-level configurations (e.g., for build or + router config files) or be configured individually. + editSettings: Edit deployment settings + manageServers: Manage deployment servers + title: Configuring deployments delete: Remove deployment new: New Deployment + pinnedDeployment: + help: Pin a deployment (and deploy to a server at least once) to enable auto-deployment. + label: Pinned deployment + placeholder: Select a deployment to pin search: Search for deployments table: creationDate: Created @@ -176,6 +209,18 @@ components: snapshot: snapshot title: Snapshots version: Version + FeedFetchFrequency: + DAYS: days + fetchFeedEvery: Fetch feed every + HOURS: hours + MINUTES: minutes + FeedInfo: + autoFetch: Auto fetch + autoPublish: Auto publish + deployable: Deployable + edit: Edit + private: Private + public: Public FeedInfoPanel: uploadShapefile: body: 'Select a zipped shapefile to display on map. Note: this is only for use as a visual aid.' @@ -192,17 +237,17 @@ components: createFirst: Create first feed source! FeedSourceTableRow: status: - active: Active - all: All - expired: Expired - expiring-within-20-days: Expiring within 20 days - expiring-within-5-days: Expiring within 5 days - feedNotInDeployment: Feed not in deployment - feedNotPublished: Feed not published - future: Future - no-version: No version - same-as-deployed: Same as Deployed - same-as-published: Same as Published + active: Active + all: All + expired: Expired + expiring-within-20-days: Expiring within 20 days + expiring-within-5-days: Expiring within 5 days + feedNotInDeployment: Feed not in deployment + feedNotPublished: Feed not published + future: Future + no-version: No version + same-as-deployed: Same as Deployed + same-as-published: Same as Published FeedSourceViewer: deploy: Deploy edit: Edit GTFS @@ -219,6 +264,10 @@ components: fetchedAutomatically: Fetched Automatically manuallyUploaded: Manually Uploaded producedInHouse: Produced In-house + producedInHouseGtfsPlus: Produced In-house (GTFS+) + regionalMerge: Regional Merge + servicePeriodMerge: Service Period Merge + versionClone: Version Clone title: Retrieval Method snapshot: Editor Snapshot title: Settings @@ -239,10 +288,16 @@ components: DeleteRecordsTransformation: label: Delete records from %tablePlaceholder% name: Delete records transformation + NormalizeFieldTransformation: + filePlaceholder: Choose file/table to normalize + label: Normalize field + name: Normalize field transformation ReplaceFileFromStringTransformation: + filePlaceholder: Choose the file/table to replace label: Replace %tablePlaceholder% from %filePlaceholder% name: Replace file from string transformation ReplaceFileFromVersionTransformation: + filePlaceholder: Choose the file/table to replace label: Replace %tablePlaceholder% from %versionPlaceholder% name: Replace file from version transformation FeedVersionNavigator: @@ -439,17 +494,35 @@ components: placeholder: Select language... Login: title: Log in + MergeFeedsResult: + body: + failure: Merge failed with %errorCount% errors. + success: Merge was completed successfully. A new version will be processed/validated containing the resulting feed. + remappedIds: "Remapped IDs: %remappedIdCount%" + skipped: "Skipped records:" + skippedTableRecords: "%table%: %skippedCount%" + strategyUsed: "Strategy used: %strategy%" + tripIdsToCheck: "Trip IDs to check: %tripIdCount%" + DEFAULT: Trip IDs were unique in the source feeds. The merged feed was successfully created. + CHECK_STOP_TIMES: Some trip IDs were found in both feeds. The merged feed was successfully created. + title: + failure: 'Warning: Errors encountered during feed merge!' + success: 'Feed merge was successful!' + NormalizeStopTimesTip: info: "Tip: when changing travel times, consider using the 'Normalize stop times' button above to automatically update all stop times to the updated travel time." + NoteForm: + adminOnly: 'Admin only?' + new: Post + postComment: Post a New Comment NotesViewer: + adminOnly: 'This message is visible to admins only.' all: All Comments feedSource: Feed Source feedVersion: Version - new: Post none: No comments. - postComment: Post a New Comment refresh: Refresh title: Comments OrganizationList: @@ -671,7 +744,7 @@ components: ShowAllRoutesOnMapFilter: fetching: Fetching showAllRoutesOnMap: Show all routes - tooManyShapeRecords: Too many shapes to display + tooManyShapeRecords: large shapes.txt may impact performance Sidebar: unknown: Unknown SnapshotItem: @@ -764,6 +837,7 @@ components: UserList: filterByOrg: Filter by org. of: of + perPage: Users per page search: Search by username showing: Showing Users title: User Management @@ -773,6 +847,8 @@ components: delete: Delete deleteConfirm: Are you sure you want to permanently delete this user? edit: Edit + missingProject: unknown + noProjectsFound: No projects orgAdmin: Org admin save: Save UserSettings: diff --git a/i18n/polish.yml b/i18n/polish.yml new file mode 100644 index 000000000..8b8fe27b1 --- /dev/null +++ b/i18n/polish.yml @@ -0,0 +1,945 @@ +_id: pl +_name: Polski +components: + Breadcrumbs: + deployments: Wdrożenia + projects: Projekty + root: Eksploruj + CreateUser: + new: Utwórz użytkownika + CreateSnapshotModal: + cancel: Anuluj + description: >- + Migawki to zapisywanie punktów, na które możesz powrócić do powrotu do + Edytowanie paszy GTFS. Do Auto-Publikuj nowy plik GTFS (i proces jako + Nowa wersja pliku danych, stwórz migawkę za pomocą opcji poniżej. + fields: + name: + label: Nazwa + placeholder: Nazwa migawki (wymagana) + comment: + label: Komentarz + placeholder: Dodatkowe informacje (opcjonalnie) + publishNewVersion: + label: Publikuj migawkę jako nowa wersja pliku + confirmPublishWithUnapproved: + label: Potwierdź publikację z niezatwierdzonymi trasami + unapprovedRoutesHeader: "Następujące trasy nie są akceptowane:" + unapprovedRoutesDesc: "Trasy te nie zostaną uwzględnione w danych wyjściowych:" + missingNameAlert: Migawce należy nadać prawidłową nazwę! + ok: OK + title: Utwórz nową migawkę + DatatoolsNavbar: + account: Moje konto + alerts: Alerty + editor: Edytor + guide: Przewodnik + login: Logowanie + logout: Wylogowanie + manager: Manager + resetPassword: Reset hasła + signConfig: eTID Config + users: Użytkownicy + DeploymentConfirmModal: + alert: + alreadyDeployed: >- + is already deployed to this server at the same router. (Deploying + would evict the current graph.) + boundsTooLarge: >- + Bounds are much too large to successfully deploy to OpenTripPlanner. + Deployment is disabled. + expiredFeeds: >- + The following feeds have expired (all scheduled trips are for past + dates) + missingBounds: >- + There are no bounds defined for the set of feeds. Deployment is + disabled. + missingFeeds: >- + There are no feeds defined for this deployment. Deployment is + disabled. + success: Deployment successfully deployed. + cancel: Anuluj + close: Zamknij + danger: Uwaga! + deploy: Wdrożenie + invalidBounds: Granice są nieprawidłowe! + to: do + warning: Ostrzeżenie! + success: Sukces! + DeploymentConfirmModalAlert: + danger: Uwaga! + success: Sukces! + warning: Ostrzeżenie! + DeploymentSettings: + boundsPlaceholder: "min_lon, min_lat, max_lon, max_lat" + buildConfig: + elevationBucket: + accessKey: Access Key + bucketName: S3 Bucket Name + secretKey: Secret Key + fares: Taryfy + fetchElevationUS: Fetch Elevation + save: Zapisz + stationTransfers: Transfery na stacji + subwayAccessTime: Godziny otwarcia metra + title: Build Config + clear: Wyczyść + manageServers: Manage deployment servers + osm: + bounds: Custom Extract Bounds + custom: Use Custom Extract Bounds + gtfs: Use GTFS-Derived Extract Bounds + title: Wyciąg z OSM + routerConfig: + brandingUrlRoot: Branding URL Root + carDropoffTime: Car Dropoff Time + numItineraries: "# of itineraries" + requestLogFile: Request log file + stairsReluctance: Stairs Reluctance + title: Router Config + updaters: + $index: + defaultAgencyId: Domyślny ID przewoźnika + frequencySec: Częstotliwość (w sekundach) + sourceType: Rodzaj źródła + type: Typ + url: URL + new: Add updater + title: Real-time updaters + placeholder: Updater name + walkSpeed: Walk Speed + save: Zapisz + title: Wdrożenie + DeploymentVersionsTable: + dateRetrieved: Data pozyskania + errorCount: Licznik błędów + expires: Wygasa + loadStatus: Załadowany prawidłowo + name: Nazwa + routeCount: Licznik linii + stopTimesCount: Licznik czasów przystankowych + tripCount: Licznik kursów + validFrom: Ważny od + version: Wersja + DeploymentViewer: + addFeedSource: Dodaj źródło kanału + allFeedsAdded: Dodano wszystkie kanały + deploy: Wdrażaj + download: Pobierz + noServers: Brak zdefiniowanych serwerów + search: Szukaj według nazwy + table: + dateRetrieved: Data pozyskania + errorCount: Licznik błędów + expires: Wygasa + loadStatus: Załadowany prawidłowo + name: Nazwa + routeCount: Licznik linii + stopTimesCount: Licznik czasów przystankowych + tripCount: Licznik kursów + validFrom: Ważny od + version: Wersja + to: do + versions: Wersje kanałów + DeploymentsList: + delete: Remove deployment + new: New Deployment + search: Search for deployments + table: + creationDate: Utworzony + lastDeployed: Ostatnio wdrożony + deployedTo: Wdrożony do + feedCount: "# of feeds" + testDeployment: Test? + name: Nazwa + title: Wdrożenia + DeploymentsPanel: + autoDeploy: + deployWithErrors: + checklabel: Deploy even if some feed versions have critical errors + help: > + If this is unchecked, an auto-deployment will halt if any of the + feed versions in the deployment have at least one critical error + title: Critical Errors Handling + help: > + A deployment will automatically be kicked off (assuming there are no + critical errors) whenever one of the above-defined events occurs. + label: Auto-deploy events + placeholder: Specify auto-deploy events + title: Auto-deployment + types: + ON_FEED_FETCH: A new version is fetched + ON_PROCESS_FEED: A new version is processed + config: + body: > + Deployments can use project-level configurations (e.g., for build or + router config files) or be configured individually. + editSettings: Edit deployment settings + manageServers: Manage deployment servers + title: Configuring deployments + delete: Remove deployment + new: New Deployment + pinnedDeployment: + help: >- + Pin a deployment (and deploy to a server at least once) to enable + auto-deployment. + label: Pinned deployment + placeholder: Select a deployment to pin + search: Search for deployments + table: + creationDate: Utworzony + lastDeployed: Ostatnio wdrożony + deployedTo: Wdrożony do + feedCount: "# kanałów" + testDeployment: Test? + name: Nazwa + title: Wdrożenia + EditorFeedSourcePanel: + active: Aktywny + confirmDelete: >- + Spowoduje to trwałe usunięcie tego zrzutu. Zapisane tutaj dane nie + mogą być odzyskane. Jesteś pewien, że chcesz kontynuować? + confirmLoad: >- + Spowoduje to zastąpienie wszystkich aktywnych danych edytora GTFS dla + tego źródła kanału danymi z tej wersji. Jeśli w Edytorze jest + niezapisana praca, którą chcesz zachować, musisz najpierw wykonać + zrzut bieżących danych Edytora. Jesteś pewien, że chcesz kontynuować? + created: utworzony + createFromScratch: Twórz GTFS od podstaw + date: Data + delete: Usuń + download: Pobierz + feed: Kanał + help: + body: + "0": >- + Migawki to punkty zapisu, do których zawsze można wrócić podczas + edytowania kanału GTFS. + "1": >- + Migawka może reprezentować pracę w toku, scenariusz przyszłego + planowania lub nawet różne wzorce usług (np. znaczniki + harmonogramu letniego). + title: Co to są migawki? + load: Wczytaj do edycji + loadLatest: Załaduj najnowszy do edycji + name: Nazwa + noSnapshotsExist: >- + Obecnie nie istnieją żadne zrzuty tego kanału. Migawki można tworzyć w + Edytorze. Kliknij „Edytuj kanał”, aby przejść do trybu edycji. + noVersions: (Brak wersji) + noVersionsExist: Brak wersji dla tego źródła kanału. + of: z + publish: Publikuj + restore: Przywróć + snapshot: migawka + title: Migawki + version: Wersja + FeedFetchFrequency: + DAYS: dni + fetchFeedEvery: Pobieraj kanał co + HOURS: godzin + MINUTES: minut + FeedInfo: # Need to check these translations. + autoFetch: Automatyczne pobieranie + autoPublish: Automatycznie publikuj + deployable: Rozmieszczany + edit: Edytować + private: Prywatny + public: Publiczny + FeedInfoPanel: + uploadShapefile: + body: >- + Wybierz spakowany plik kształtu do wyświetlenia na mapie. Uwaga: + służy tylko jako pomoc wizualna. + error: Przesłany plik musi być prawidłowym plikiem zip (.zip). + title: Prześlij plik kształtu trasy + FeedSourceAttributes: + lastUpdated: Zaktualizowano + FeedSourcePanel: + search: Wyszukaj kanały + FeedSourceTable: + comparisonColumn: + DEPLOYED: Wdrożona wersja + PUBLISHED: Wersja opublikowana + createFirst: Utwórz pierwsze źródło kanału! + FeedSourceTableRow: + status: + active: Aktywny + all: Wszystkie + expired: Wygasłe + expiring-within-20-days: Wygasające w ciągu 20 dni + expiring-within-5-days: Wygasające w ciągu 5 dni + feedNotInDeployment: Kanały niebędące w wdrożeniu + feedNotPublished: Kanały nieopublikowane + future: W przyszłości + no-version: Brak wersji + same-as-deployed: Taki sam jak wdrożony + same-as-published: Taki sam jak opublikowany + FeedSourceViewer: + deploy: Wdrażaj + edit: Edytuj GTFS + gtfs: GTFS + notesTitle: Notatki + private: Widok prywatny + properties: + deployable: Do wdrożenia? + name: Nazwa + noneSelected: (Nie wybrano) + property: Właściwość + public: Publiczny? + retrievalMethod: + fetchedAutomatically: Fetched Automatically + manuallyUploaded: Manually Uploaded + producedInHouse: Produced In-house + producedInHouseGtfsPlus: Produced In-house (GTFS+) + regionalMerge: Regional Merge + servicePeriodMerge: Service Period Merge + versionClone: Version Clone + title: Retrieval Method + snapshot: Migawka edytora + title: Ustawienia + value: Wartość + snapshotsTitle: Migawki + update: Aktualizacja + upload: Upload + versions: Wersje + viewPublic: View public page + FeedTransformationDescriptions: + general: + fileDefined: below text + filePlaceholder: "[choose file]" + tablePlaceholder: "[choose table]" + table: table + version: version + versionPlaceholder: "[choose version]" + DeleteRecordsTransformation: + label: Delete records from %tablePlaceholder% + name: Delete records transformation + NormalizeFieldTransformation: + filePlaceholder: Choose file/table to normalize + label: Normalize field + name: Normalize field transformation + ReplaceFileFromStringTransformation: + filePlaceholder: Choose the file/table to replace + label: Replace %tablePlaceholder% from %filePlaceholder% + name: Replace file from string transformation + ReplaceFileFromVersionTransformation: + filePlaceholder: Choose the file/table to replace + label: Replace %tablePlaceholder% from %versionPlaceholder% + name: Replace file from version 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 + 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? + delete: Delete + download: Download + feed: Feed + load: Load for Editing + next: Next + of: of + previous: Previous + version: Version + FeedVersionTabs: + agencyCount: Agency count + daysActive: Days active + routeCount: Route count + stopCount: Stop count + stopTimesCount: Stop time count + tripCount: Trip count + validDates: Valid Dates + FeedVersionViewer: + 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? + delete: Delete + download: Download + feed: Feed + load: Load + noVersionsExist: No versions exist for this feed source. + status: Status + timestamp: File Timestamp + version: version + FormInput: + buildConfig: + elevationBucket: + accessKey: Access Key + bucketName: S3 Bucket Name + secretKey: Secret Key + fares: Fares + fetchElevationUS: Fetch Elevation + stationTransfers: Sta. Transfers + subwayAccessTime: Subway Access Time + title: Build Config + deployment: + osm: + bounds: Custom Extract Bounds + custom: Use Custom Extract Bounds + gtfs: Use GTFS-Derived Extract Bounds + title: OSM Extract + title: Deployment + routerConfig: + brandingUrlRoot: Branding URL Root + carDropoffTime: Car Dropoff Time + numItineraries: "# of itineraries" + requestLogFile: Request log file + stairsReluctance: Stairs Reluctance + title: Router Config + updaters: + $index: + defaultAgencyId: Default agency ID + frequencySec: Frequency (in seconds) + sourceType: Source type + type: Type + url: URL + new: Add updater + title: Real-time updaters + placeholder: Updater name + walkSpeed: Walk Speed + otpServers: + $index: + admin: Admin access only? + delete: Remove + ec2Info: + amiId: AMI ID + buildAmiId: Graph build AMI ID + buildImageDescription: New Image Description + buildImageName: New Image Name + buildInstanceType: Graph build instance type + instanceCount: Instance count + instanceType: Instance type + iamInstanceProfileArn: IAM Instance Profile ARN + keyName: Key file name + recreateBuildImage: Recreate Build Image after Graph Build? + region: Region name + securityGroupId: Security Group ID + subnetId: Subnet ID + targetGroupArn: Target Group ARN (load balancer) + internalUrl: Internal URLs + name: Name + namePlaceholder: Production + publicUrl: Public URL + role: AWS Role + s3Bucket: S3 bucket name + serverPlaceholder: Server name + title: Servers + GeneralSettings: + confirmDelete: >- + Are you sure you want to delete this project? This action cannot be + undone and all feed sources and their versions will be permanently + deleted. + deleteProject: Delete Project? + deployment: + buildConfig: + elevationBucket: + accessKey: Access Key + bucketName: S3 Bucket Name + secretKey: Secret Key + fares: Fares + fetchElevationUS: Fetch Elevation + stationTransfers: Sta. Transfers + subwayAccessTime: Subway Access Time + title: Build Config + osm: + bounds: Custom Extract Bounds + custom: Use Custom Extract Bounds + gtfs: Use GTFS-Derived Extract Bounds + title: OSM Extract + otpServers: + $index: + admin: Admin access only? + delete: Remove + internalUrl: Internal URLs + name: Name + namePlaceholder: Production + publicUrl: Public URL + s3Bucket: S3 bucket name + new: Add server + serverPlaceholder: Server name + title: Servers + routerConfig: + brandingUrlRoot: Branding URL Root + carDropoffTime: Car Dropoff Time + numItineraries: "# of itineraries" + requestLogFile: Request log file + stairsReluctance: Stairs Reluctance + title: Router Config + updaters: + $index: + defaultAgencyId: Default agency ID + frequencySec: Frequency (in seconds) + sourceType: Source type + type: Type + url: URL + new: Add updater + title: Real-time updaters + placeholder: Updater name + walkSpeed: Walk Speed + title: Deployment + general: + location: + boundingBox: "Bounding box (W,S,E,N)" + defaultLanguage: Default language + defaultLocation: "Default location (lat, lng)" + defaultTimeZone: Default time zone + title: Location + name: Project name + title: General + updates: + autoFetchFeeds: Auto fetch feed sources? + title: Updates + rename: Rename + save: Save + title: Settings + GtfsValidationExplorer: + accessibilityValidation: Accessibility Explorer + table: + count: Count + file: File + issue: Issue + priority: Priority + timeValidation: Time-based Validation + title: Validation Explorer + validationIssues: Validation Issues + GtfsValidationViewer: + explorer: Validation Explorer + issues: + other: Other issues + routes: Route issues + shapes: Shape issues + stop_times: Stop times issues + stops: Stop issues + trips: Trip issues + noResults: No validation results to show. + tips: + DATE_NO_SERVICE: >- + If the transit service does not operate on weekends, some or all of + these validation issue may be ignored. Similarly, holidays for which + there is no transit service running may appear in this list. + FEED_TRAVEL_TIMES_ROUNDED: >- + This is a common feature of GTFS feeds that do not use + down-to-the-second precision for arrival/departure times. However, + if this precision is expected, there may be an issue occurring + during feed export. + MISSING_TABLE: >- + Missing a required table is a major issue that must be resolved + before most GTFS consumers can make use of the data for trip + planning or in other applications. + title: Validation issues + LanguageSelect: + placeholder: Select language... + Login: + title: Log in + NormalizeStopTimesTip: + info: >- + Tip: when changing travel times, consider using the 'Normalize stop + times' button above to automatically update all stop times to the + updated travel time. + NoteForm: + postComment: "TODO: Translate" + new: "TODO: Translate" + adminOnly: "TODO: Translate" + NotesViewer: + all: All Comments + feedSource: Feed Source + feedVersion: Version + new: Post + none: No comments. + postComment: Post a New Comment + refresh: Refresh + title: Comments + adminOnly: "TODO: Translate" + OrganizationList: + new: Create org + search: Search orgs + OrganizationSettings: + extensions: Extensions + logoUrl: + label: Logo URL + placeholder: "http://example.com/logo_30x30.png" + name: + label: Name + placeholder: Big City Transit + orgDetails: Organization details + projects: Projects + subDetails: Subscription details + subscriptionBeginDate: Subscription begins + subscriptionEndDate: Subscription ends + usageTier: + high: High + low: Low + medium: Medium + ProjectAccessSettings: + admin: Admin + cannotFetchFeeds: Cannot fetch feeds + custom: Custom + feeds: Feed Sources + noAccess: No Access + permissions: Permissions + title: Project Settings for + ProjectSettings: + deployment: + buildConfig: + elevationBucket: + accessKey: Access Key + bucketName: S3 Bucket Name + secretKey: Secret Key + fares: Fares + fetchElevationUS: Fetch Elevation + stationTransfers: Sta. Transfers + subwayAccessTime: Subway Access Time + title: Build Config + osm: + bounds: Custom Extract Bounds + custom: Use Custom Extract Bounds + gtfs: Use GTFS-Derived Extract Bounds + title: OSM Extract + otpServers: + $index: + admin: Admin access only? + delete: Remove + internalUrl: Internal URLs + name: Name + namePlaceholder: Production + publicUrl: Public URL + r5: R5 Server? + s3Bucket: S3 bucket name + targetGroupArn: Target Group ARN + new: Add server + serverPlaceholder: Server name + title: Servers + routerConfig: + brandingUrlRoot: Branding URL Root + carDropoffTime: Car Dropoff Time + numItineraries: "# of itineraries" + requestLogFile: Request log file + stairsReluctance: Stairs Reluctance + title: Router Config + updaters: + $index: + defaultAgencyId: Default agency ID + frequencySec: Frequency (in seconds) + sourceType: Source type + type: Type + url: URL + new: Add updater + title: Real-time updaters + placeholder: Updater name + walkSpeed: Walk Speed + title: Deployment + project: + cannotFetchFeeds: Cannot fetch feeds + feeds: Feeds + permissions: Permissions + rename: Rename + save: Save + title: Settings + ProjectSettingsForm: + cancel: Cancel + confirmDelete: >- + Are you sure you want to delete this project? This action cannot be + undone and all feed sources and their versions will be permanently + deleted. + deleteProject: Delete Project? + fields: + location: + boundingBox: "Bounding box (W,S,E,N)" + boundingBoxPlaceHolder: "min_lon, min_lat, max_lon, max_lat" + defaultLanguage: Default language + defaultLocation: "Default location (lat, lng)" + defaultTimeZone: Default time zone + title: Location + name: Project name + title: General + updates: + autoFetchFeeds: Auto fetch feed sources? + title: Updates + save: Save + title: Settings + ProjectViewer: + deployments: Deployments + feeds: + createFirst: Create first feed source! + new: New Feed Source + search: Search by name + table: + deployable: Deployable? + errorCount: Errors + lastUpdated: Updated + name: Name + public: Public? + retrievalMethod: Retrieval Method + validRange: Valid Range + title: Feed Sources + update: Fetch all + makePublic: Publish public feeds + mergeFeeds: Merge all + settings: Settings + ProjectFeedListToolbar: + comparison: + DEPLOYED: Deployed + LATEST: Latest + PUBLISHED: Published + deployments: Deployments + downloadCsv: Download Summary as CSV + feeds: + createFirst: Create first feed source! + new: New + search: Search by name + table: + deployable: Deployable? + errorCount: Errors + lastUpdated: Updated + name: Name + public: Public? + retrievalMethod: Retrieval Method + validRange: Valid Range + title: Feed Sources + update: Fetch all + filter: + active: Active + all: All + expired: Expired + expiring: Expiring + future: Future + makePublic: Publish public feeds + mergeFeeds: Merge all + settings: Settings + sort: + alphabetically: + title: Alphabetically + asc: A-Z + desc: Z-A + endDate: + title: Expiration Date + asc: Earliest-Latest + desc: Latest-Earliest + lastUpdated: + title: Last Update + asc: Stale-Recent + desc: Recent-Stale + numErrors: + title: Number of Issues + asc: Least-Most + desc: Most-Least + startDate: + title: Start Date + asc: Earliest-Latest + desc: Latest-Earliest + sync: + transitland: Sync from transit.land + transitfeeds: Sync from transitfeeds + mtc: Sync from MTC + ProjectsList: + 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. + title: What's a project? + new: New Project + noProjects: You currently do not have any projects. + search: Search by project name + table: + name: Project Name + title: Projects + PublicFeedsTable: + country: Country + lastUpdated: Last Updated + link: Link to GTFS + name: Feed Name + region: Region + search: Search + stateProvince: State or Province + PublicFeedsViewer: + title: Catalogue + RegionSearch: + placeholder: Search for regions or agencies + ResultTable: + affectedIds: Affected ID(s) + description: Description + line: Line + priority: Priority + problemType: Problem Type + ServerSettings: + deployment: + otpServers: + new: Add server + refresh: Refresh + serverPlaceholder: Server name + title: Deployment Server Management + save: Save + title: Settings + ShowAllRoutesOnMapFilter: + fetching: Fetching + showAllRoutesOnMap: Show all routes + tooManyShapeRecords: large shapes.txt may impact performance + Sidebar: + unknown: Unknown + SnapshotItem: + active: Active + confirmDelete: >- + This will permanently delete this snapshot. Any data saved here cannot + be recovered. Are you sure you want to continue? + 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? + created: created + createFromScratch: Create GTFS from Scratch + date: Date + delete: Delete + download: Download + feed: Feed + load: Load for Editing + loadLatest: Load latest for editing + name: Name + noVersions: (No Versions) + noVersionsExist: No versions exist for this feed source. + of: of + publish: Publish + restore: Restore + snapshot: snapshot + title: Snapshots + version: Version + StarButton: + star: Star + unstar: Unstar + TimetableHelpModal: + title: Timetable editor keyboard shortcuts + shortcuts: + offset: + title: Offsetting times + desc: + "0": Offset selected trips' stop times by adding offset time + "1": Offset selected trips' stop times by subtracting offset time + "2": Offset only active cell's time by adding offset time + "3": Offset only active cell's time by subtracting offset time + "4": Decrease offset time by 1 minute + "5": Decrease offset time by 10 minutes + "6": Increase offset time by 1 minute + "7": Increase offset time by 10 minutes + navigate: + title: Navigating and selecting trips + desc: + "0": Previous/next trip + "1": Previous/next column + "2": Select trip + "3": Select all trips + "4": Deselect all trips + modify: + title: Modify trips + desc: + "0": Delete selected trip(s) + "1": New trip + "2": Clone selected trip(s) + "3": >- + Copy time value from adjacent cell (the cell immediately to the + left) + "4": Copy value from cell directly above + TimezoneSelect: + placeholder: Select timezone... + UserAccount: + account: + title: Account + billing: + title: Billing + notifications: + methods: Notification methods + subscriptions: Your subscriptions + title: Notifications + unsubscribeAll: Unsubscribe from all + organizationSettings: Organization settings + organizations: + title: Organizations + personalSettings: Personal settings + profile: + profileInformation: Profile information + title: Profile + title: My settings + AdminPage: + noAccess: You do not have sufficient user privileges to access this area. + title: Administration + UserHomePage: + 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. + title: What's a project? + new: New Project + noProjects: You currently do not have any projects. + table: + name: Project Name + title: Projects + UserList: + filterByOrg: Filter by org. + of: of + perPage: Users per page + search: Search by username + showing: Showing Users + title: User Management + UserRow: + appAdmin: App admin + cancel: Cancel + delete: Delete + deleteConfirm: Are you sure you want to permanently delete this user? + edit: Edit + missingProject: unknown + noProjectsFound: No projects + orgAdmin: Org admin + save: Save + UserSettings: + admin: + description: Application administrators have full access to all projects. + title: Application Admininistrator + application: Application Settings + cancel: Cancel + delete: Delete + edit: Edit + org: + admin: Organization administrator + billing: Billing admin + description: >- + Organization administrators have full access to projects within the + organization. + project: + admin: Admin + custom: Custom + noAccess: No Access + save: Save + 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? + delete: Delete + download: Download + feed: Feed + load: Load + noVersionsExist: No versions exist for this feed source. + status: Status + timestamp: File Timestamp + version: version + WatchButton: + emailVerificationConfirm: >- + In order to receive email notification, you must first verify your + email address. Would you like to resend the email verification message + for your account? + unwatch: Unwatch + verificationSendError: There was an error sending the email verification! + verificationSent: >- + Please check your inbox and confirm your email address by clicking the + verify link. You will then need to refresh this page after a moment or + two and re-click the Watch button to subscribe to notifications. + watch: Watch diff --git a/lib/admin/actions/admin.js b/lib/admin/actions/admin.js index 739d1efe5..493a84349 100644 --- a/lib/admin/actions/admin.js +++ b/lib/admin/actions/admin.js @@ -1,6 +1,7 @@ // @flow import {createAction, type ActionType} from 'redux-actions' +import qs from 'qs' import {createVoidPayloadAction, secureFetch} from '../../common/actions' import {setErrorMessage} from '../../manager/actions/status' @@ -32,6 +33,10 @@ const receiveAllRequests = createAction( ) const fetchingApplicationStatus = createVoidPayloadAction('FETCHING_ALL_JOBS') const updatingServer = createAction('UPDATING_SERVER', (payload: OtpServer) => payload) +const receiveServer = createAction( + 'RECEIVE_SERVER', + (payload: OtpServer) => payload +) const receiveServers = createAction( 'RECEIVE_SERVERS', (payload: Array) => payload @@ -43,6 +48,10 @@ export const setUserPage = createAction( 'SET_USER_PAGE', (payload: number) => payload ) +export const setUserPerPage = createAction( + 'SET_USER_PER_PAGE', + (payload: number) => payload +) export const setUserQueryString = createAction( 'SET_USER_QUERY_STRING', (payload: string) => payload @@ -52,6 +61,7 @@ export type AdminActions = ActionType | ActionType | ActionType | ActionType | + ActionType | ActionType | ActionType | ActionType | @@ -60,17 +70,15 @@ export type AdminActions = ActionType | export function fetchUsers () { return function (dispatch: dispatchFn, getState: getStateFn) { dispatch(requestingUsers()) - const queryString = getState().admin.users.userQueryString - + const {page, perPage, userQueryString: queryString} = getState().admin.users let countUrl = '/api/manager/secure/usercount' - if (queryString) countUrl += `?queryString=${queryString}` + if (queryString) countUrl += `?${qs.stringify({queryString})}` const getCount = dispatch(secureFetch(countUrl)) - .then(response => response.json()) - - let usersUrl = `/api/manager/secure/user?page=${getState().admin.users.page}` - if (queryString) usersUrl += `&queryString=${queryString}` + .then(res => res.json()) + const params = queryString ? {page, perPage, queryString} : {page, perPage} + const usersUrl = `/api/manager/secure/user?${qs.stringify(params)}` const getUsers = dispatch(secureFetch(usersUrl)) - .then(response => response.json()) + .then(res => res.json()) Promise.all([getCount, getUsers]).then((results) => { if (Array.isArray(results[1])) { @@ -147,6 +155,17 @@ export function fetchServers () { } } +/** + * Fetch an OTP/R5 server target. + */ +export function fetchServer (serverId: string) { + return function (dispatch: dispatchFn, getState: getStateFn) { + return dispatch(secureFetch(`${SERVER_URL}/${serverId}`)) + .then(res => res.json()) + .then(server => dispatch(receiveServer(server))) + } +} + /** * Update or create OTP/R5 server target. */ @@ -158,7 +177,7 @@ export function updateServer (server: OtpServer) { dispatch(updatingServer(server)) dispatch(secureFetch(url, method, server)) .then(res => res.json()) - .then(server => dispatch(fetchServers())) + .then(server => dispatch(receiveServer(server))) } } diff --git a/lib/admin/components/ServerSettings.js b/lib/admin/components/ServerSettings.js index e3e344c6b..523e0e72e 100644 --- a/lib/admin/components/ServerSettings.js +++ b/lib/admin/components/ServerSettings.js @@ -36,6 +36,7 @@ import type { AppState, AdminServersState } from '../../types/reducers' type ContainerProps = {editDisabled: boolean} type Props = ContainerProps & { deleteServer: typeof adminActions.deleteServer, + fetchServer: typeof adminActions.fetchServer, fetchServers: typeof adminActions.fetchServers, otpServers: AdminServersState, projects: Array, @@ -149,6 +150,10 @@ class ServerSettings extends Component { } } + _onExpandServer = (index: number) => { + this.props.fetchServer(this.state.otpServers[index].id) + } + _onSaveServer = (index: number) => this.props.updateServer(this.state.otpServers[index]) _onChange = (evt, stateUpdate = {}, index) => { @@ -224,6 +229,7 @@ class ServerSettings extends Component { index={index} key={`server-${index}`} onChange={this._getOnChange} + onEnter={this._onExpandServer} onRemove={this._onDeleteServer} onSave={this._onSaveServer} saveDisabled={!this.state.hasEdits} @@ -377,6 +383,7 @@ const mapStateToProps = (state: AppState, ownProps: ContainerProps) => { const { deleteServer, + fetchServer, fetchServers, terminateEC2Instances, updateServer @@ -386,6 +393,7 @@ const { terminateEC2InstanceForDeployment } = deploymentActions const mapDispatchToProps = { deleteServer, + fetchServer, fetchServers, terminateEC2InstanceForDeployment, terminateEC2Instances, diff --git a/lib/admin/components/UserList.js b/lib/admin/components/UserList.js index bf8c6ecd5..61221a57d 100644 --- a/lib/admin/components/UserList.js +++ b/lib/admin/components/UserList.js @@ -3,9 +3,21 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' import {connect} from 'react-redux' -import {Panel, Row, Col, Button, ButtonGroup, InputGroup, FormControl, ListGroup, ListGroupItem} from 'react-bootstrap' +import { + Button, + ButtonGroup, + Col, + DropdownButton, + InputGroup, + FormControl, + ListGroup, + ListGroupItem, + Panel, + Row +} from 'react-bootstrap' import * as adminActions from '../actions/admin' +import MenuItem from '../../common/components/MenuItem' import {getComponentMessages} from '../../common/util/config' import CreateUser from './CreateUser' import * as feedActions from '../../manager/actions/feeds' @@ -27,6 +39,7 @@ type Props = { perPage: number, projects: Array, setUserPage: typeof adminActions.setUserPage, + setUserPerPage: typeof adminActions.setUserPerPage, setUserQueryString: typeof adminActions.setUserQueryString, token: string, updateUserData: typeof managerUserActions.updateUserData, @@ -75,6 +88,12 @@ class UserList extends Component { fetchUsers() } + _setUserPerPage = (perPage: number) => { + const {fetchUsers, setUserPerPage} = this.props + setUserPerPage(perPage) + fetchUsers() + } + _userSearch = () => { const {fetchUsers, setUserPage, setUserQueryString} = this.props setUserPage(0) @@ -132,6 +151,21 @@ class UserList extends Component { : (No results to show) } +
+ {this.messages('perPage')}{' '} + + {// Render users per page options. + [10, 25, 50, 100].map(val => + + {val} + + ) + } + +
{ this.toggleExpansion() } + _getOrgLabel = (permissions: UserPermissions) => { + const {creatingUser, organizations} = this.props + if (!organizations) return null + const userOrganization = organizations.find(o => o.id === permissions.getOrganizationId()) + const creatorIsApplicationAdmin = creatingUser.permissions && + creatingUser.permissions.isApplicationAdmin() + return userOrganization && creatorIsApplicationAdmin + ? {userOrganization.name} + : null + } + + /** + * Constructs label indicating user authorization level (e.g., app/org admin) + * or listing the projects the user has access to. + */ + _getUserPermissionLabel = (permissions: UserPermissions) => { + const {projects} = this.props + // Default label to no projects found. + let labelText = this.messages('noProjectsFound') + let missingProjectCount = 0 + let labelStyle, title + if (permissions.isApplicationAdmin()) { + labelStyle = 'danger' + labelText = this.messages('appAdmin') + } else if (permissions.canAdministerAnOrganization()) { + labelStyle = 'warning' + labelText = this.messages('orgAdmin') + } else { + const missingProjectIds = [] + // Find project names for any projects that exist. + const projectNames = Object.keys(permissions.projectLookup) + .map(id => { + const project = projects.find(p => p.id === id) + // Use name of project for label (or track missing project with uuid). + // A missing project can occur when the same Auth0 tenant is used for + // multiple instances of Data Tools or if a project is deleted (but + // the permission is still attached to the user). + if (project) return project.name + missingProjectCount++ + missingProjectIds.push(id) + return MISSING_PROJECT_VALUE + }) + .filter(name => name) + const uniqueProjectNames = Array.from(new Set(projectNames)) + // Build message based on number of projects. + if (uniqueProjectNames.length > 0) { + if (missingProjectCount > 0) { + // If any missing project ids, use warning label and show in title. + labelStyle = 'warning' + title = `${this.messages('missingProject')}: ${missingProjectIds.join(', ')}` + } else { + labelStyle = 'info' + } + labelText = uniqueProjectNames + // Replace uuid with missing project count message. + .map(name => name === MISSING_PROJECT_VALUE + ? `${missingProjectCount} ${this.messages('missingProject')}` + : name + ) + .join(', ') + } + } + return {labelText} + } + save = () => { const settings = this.refs.userSettings.getSettings() this.props.updateUserData(this.props.user, settings) @@ -69,7 +138,6 @@ export default class UserRow extends Component { const permissions = new UserPermissions(user.app_metadata && user.app_metadata.datatools) const creatorIsApplicationAdmin = creatingUser.permissions && creatingUser.permissions.isApplicationAdmin() - const userOrganization = organizations.find(o => o.id === permissions.getOrganizationId()) const creatorDoesNotHaveOrg = !creatingUser.permissions || // $FlowFixMe !creatingUser.permissions.hasOrganization(permissions.getOrganizationId()) @@ -92,16 +160,9 @@ export default class UserRow extends Component {
{user.email}{' '} - {permissions.isApplicationAdmin() - ? {this.messages('appAdmin')} - : permissions.canAdministerAnOrganization() - ? {this.messages('orgAdmin')} - : null - }{' '} - {userOrganization && creatorIsApplicationAdmin - ? {userOrganization.name} - : null - } + {this._getUserPermissionLabel(permissions)} + {' '} + {this._getOrgLabel(permissions)}
diff --git a/lib/admin/reducers/servers.js b/lib/admin/reducers/servers.js index cb5f43ed2..225a52e6e 100644 --- a/lib/admin/reducers/servers.js +++ b/lib/admin/reducers/servers.js @@ -19,6 +19,18 @@ const servers = (state: AdminServersState = defaultState, action: Action): Admin isFetching: { $set: false }, data: { $set: action.payload } }) + case 'RECEIVE_SERVER': + const serverData = action.payload + if (state.data) { + const serverIdx = state.data.findIndex( + server => server.id === serverData.id + ) + return update(state, { + isFetching: { $set: false }, + data: { [serverIdx]: { $set: action.payload } } + }) + } + return state case 'CREATED_SERVER': if (state.data) { return update(state, { data: { $push: [action.payload] } }) diff --git a/lib/admin/reducers/users.js b/lib/admin/reducers/users.js index af031d6b5..6e12c9eab 100644 --- a/lib/admin/reducers/users.js +++ b/lib/admin/reducers/users.js @@ -6,11 +6,11 @@ import type {Action} from '../../types/actions' import type {AdminUsersState} from '../../types/reducers' export const defaultState = { - isFetching: false, data: null, - userCount: 0, + isFetching: false, page: 0, perPage: 10, + userCount: 0, userQueryString: null } @@ -32,6 +32,8 @@ const users = (state: AdminUsersState = defaultState, action: Action): AdminUser return state case 'SET_USER_PAGE': return update(state, {page: { $set: action.payload }}) + case 'SET_USER_PER_PAGE': + return update(state, {perPage: { $set: action.payload }}) case 'SET_USER_QUERY_STRING': return update(state, {userQueryString: { $set: action.payload }}) default: diff --git a/lib/alerts/components/AlertEditor.js b/lib/alerts/components/AlertEditor.js index 0e1e26fd2..0fe7ffe6a 100644 --- a/lib/alerts/components/AlertEditor.js +++ b/lib/alerts/components/AlertEditor.js @@ -70,7 +70,7 @@ export default class AlertEditor extends Component { validateAndSave = () => { const {alert, saveAlert} = this.props - const {affectedEntities, description, end, start, title} = alert + const {affectedEntities, end, start, title} = alert const momentEnd = moment(end) const momentStart = moment(start) @@ -78,13 +78,6 @@ export default class AlertEditor extends Component { if (!title.trim()) { return window.alert('You must specify an alert title') } - // alert title/description must meet character limits (for display purposes) - if (title.length > ALERT_TITLE_CHAR_LIMIT) { - return window.alert(`Alert title must be ${ALERT_TITLE_CHAR_LIMIT} characters or less`) - } - if (description && description.length > ALERT_DESCRIPTION_CHAR_LIMIT) { - return window.alert(`Alert description must be ${ALERT_DESCRIPTION_CHAR_LIMIT} characters or less`) - } if (!end || !start || !momentEnd.isValid() || !momentStart.isValid()) { return window.alert('Alert must have a valid start and end date') } @@ -146,7 +139,14 @@ export default class AlertEditor extends Component { const descriptionCharactersRemaining = alert.description ? ALERT_DESCRIPTION_CHAR_LIMIT - alert.description.length : ALERT_DESCRIPTION_CHAR_LIMIT - const canPublish = alert.affectedEntities.length && + const titleCharacterCount = alert.title + ? alert.title.length + : 0 + const descriptionCharactersCount = alert.description + ? alert.description.length + : 0 + const canPublish = + alert.affectedEntities.length && checkEntitiesForFeeds(alert.affectedEntities, publishableFeeds) const canEdit = checkEntitiesForFeeds(alert.affectedEntities, editableFeeds) const editingIsDisabled = alert.published && !canPublish ? true : !canEdit @@ -221,10 +221,17 @@ export default class AlertEditor extends Component { : 'text-danger' } style={{fontWeight: 400}}> - {titleCharactersRemaining} + {titleCharacterCount}
+ {titleCharacterCount > ALERT_TITLE_CHAR_LIMIT && + ( + + WARNING: Alert title longer than {ALERT_TITLE_CHAR_LIMIT} characters may get truncated in some dissemination channels.
+
+ ) + } Note: alert title serves as text for eTID alerts. Use descriptive language so it can serve as a standalone alert. @@ -290,7 +297,7 @@ export default class AlertEditor extends Component { - + Description @@ -302,18 +309,28 @@ export default class AlertEditor extends Component { : 'text-danger' } style={{fontWeight: 400}}> - {descriptionCharactersRemaining} + {descriptionCharactersCount} + {descriptionCharactersCount > ALERT_DESCRIPTION_CHAR_LIMIT && + ( +
+ + WARNING: Alert description longer than {ALERT_DESCRIPTION_CHAR_LIMIT} characters may get truncated in some dissemination channels. + +
+ ) + }
+ onChange={this._onChange} + style={{ minHeight: '89px' }} />
- + URL = 500) { + errorMessage = `Network error (${status})!\n\n(${method} request on ${url})` + } + return errorMessage +} + export function createVoidPayloadAction (type: string) { return () => ({ type }) } -export function secureFetch (url: string, method: string = 'get', payload?: any, raw: boolean = false, isJSON: boolean = true, actionOnFail?: string): any { +/** + * Shorthand fetch call to pass a file as formData on a POST request to the + * specified URL. + */ +export function postFormData (url: string, file: File, customHeaders?: {[string]: string}) { return function (dispatch: dispatchFn, getState: getStateFn) { - function consoleError (message) { - console.error(`Error making ${method} request to ${url}: `, message) + if (!file) { + alert('No file to upload!') + return } + const data = new window.FormData() + data.append('file', file) + return dispatch(secureFetch(url, 'post', data, false, false, undefined, customHeaders)) + } +} +export function secureFetch ( + url: string, + method: string = 'get', + payload?: any, + raw: boolean = false, + isJSON: boolean = true, + actionOnFail?: string, + customHeaders?: {[string]: string} +): any { + return function (dispatch: dispatchFn, getState: getStateFn) { // if running in a test environment, fetch will complain when using relative // urls, so prefix all urls with http://localhost:4000. if (process.env.NODE_ENV === 'test') { @@ -31,7 +63,8 @@ export function secureFetch (url: string, method: string = 'get', payload?: any, } const headers: {[string]: string} = { 'Authorization': `Bearer ${token}`, - 'Accept': 'application/json' + 'Accept': 'application/json', + ...customHeaders } if (isJSON) { headers['Content-Type'] = 'application/json' @@ -42,43 +75,60 @@ export function secureFetch (url: string, method: string = 'get', payload?: any, return fetch(url, {method, headers, body}) // Catch basic error during fetch .catch(err => { - const message = `Error making request: (${err})` - consoleError(message) + const message = getErrorMessage(method, url) + console.error(message, err) return dispatch(setErrorMessage({message})) }) - .then(res => { - // if raw response is requested - if (raw) return res - let action, message - // check for errors - const {status} = res - if (status >= 500) { - action = 'RELOAD' - message = `Network error!\n\n(${method} request on ${url})` - consoleError(message) - dispatch(setErrorMessage({message, action})) - return null - } else if (status >= 400) { - action = status === 401 - ? 'LOG_IN' - : actionOnFail - res.json() - .then(json => { - const {detail, message} = json - const unknown = `Unknown (${status}) error occurred while making request` - consoleError(message || JSON.stringify(json) || unknown) - dispatch(setErrorMessage({ - message: message || JSON.stringify(json) || unknown, - action, - detail - })) - }) - return null - } else { - return res - } - }) + .then(res => dispatch(handleResponse(method, url, res, raw, actionOnFail))) + } +} + +function handleResponse (method, url, res, raw, actionOnFail) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + // if raw response is requested + if (raw) return res + const {status} = res + // Return response with no further action if there are no errors. + if (status < 400) return res + // check for errors + let json + try { + json = await res.json() + } catch (e) { + console.warn('Could not parse JSON from error response') + } + const errorMessage = getErrorMessageFromJson(json, status, method, url, actionOnFail) + dispatch(setErrorMessage(errorMessage)) + return null + } +} + +function getErrorMessageFromJson ( + json: ?ErrorResponse, + status, + method, + url, + actionOnFail +) { + let action = 'RELOAD' + let detail + let errorMessage = getErrorMessage(method, url, status) + if (status < 500) { + action = status === 401 + ? 'LOG_IN' + : actionOnFail + } + if (json) { + detail = json.detail + // if status >= 500 and detail is being overrode, add original network error in small text + if (status >= 500) { + detail = detail ? detail + errorMessage : errorMessage + } + // re-assign error message after it gets used in the detail. + errorMessage = json.message || JSON.stringify(json) } + console.error(errorMessage) + return { action, detail, message: errorMessage } } function graphQLErrorsToString (errors: Array<{locations: any, message: string}>): Array { diff --git a/lib/common/components/EditableTextField.js b/lib/common/components/EditableTextField.js index 2cb4e269a..1dcc2db6b 100644 --- a/lib/common/components/EditableTextField.js +++ b/lib/common/components/EditableTextField.js @@ -94,8 +94,8 @@ export default class EditableTextField extends Component { handleKeyDown = (e: SyntheticKeyboardEvent) => { switch (e.keyCode) { - case 9: // [Enter] - case 13: // [Tab] + case 9: // [Tab] + case 13: // [Enter] e.preventDefault() if (this.state.isEditing) { this._save(e) diff --git a/lib/common/components/FeedLabel.js b/lib/common/components/FeedLabel.js new file mode 100644 index 000000000..9936513d5 --- /dev/null +++ b/lib/common/components/FeedLabel.js @@ -0,0 +1,158 @@ +// @flow +import React from 'react' +import tinycolor from 'tinycolor2' +import Icon from '@conveyal/woonerf/components/icon' +import { connect } from 'react-redux' + +import { deleteLabel } from '../../manager/actions/labels' +import type { Label } from '../../types' +import type {ManagerUserState} from '../../types/reducers' +import ConfirmModal from '../../common/components/ConfirmModal' +import LabelEditorModal from '../../manager/components/LabelEditorModal' + +export type Props = { + checked?: boolean, + deleteLabel: Function, + editable?: boolean, + label: Label, + onClick?: Function, + user?: ManagerUserState +} +export type State = {} + +/** + * Generate lightened/darkened versions of a color for use in text and border rendering + * @param {string} cssHex The css hex value to modify + * @param {number} strength The amount to lighten or darken by + * @returns String with lightened/darkened css hex value + */ +const getComplementaryColor = (cssHex, strength) => { + const color = tinycolor(cssHex) + + const complementary = color.isDark() + ? color.lighten(strength) + : color.darken(strength + 10) + return complementary.toHexString() +} + +/** + * Renders a feed label, either large or small and optionally renders a checkbox or edit/delete + * buttons alongside the label + */ +class FeedLabel extends React.Component { + _onConfirmDelete = () => { + this.props.deleteLabel && this.props.deleteLabel(this.props.label) + } + + _onClickDelete = () => { + this.refs.deleteModal.open() + } + + _getWrapperClasses = () => { + const classes = ['feedLabelWrapper'] + if (this._isEditable()) classes.push('withButtons') + return classes.join(' ') + } + + _getLabelClasses = () => { + const classes = ['feedLabel'] + classes.push('smaller') + if (this._isCheckable()) classes.push('clickable') + return classes.join(' ') + } + + _isCheckable = () => this.props.checked !== undefined + + _onClickEdit = () => { + this.refs.editModal.open() + } + + _onClick = () => { + const {label, onClick} = this.props + onClick && onClick(label.id) + } + + _isEditable = () => { + const {editable, label, user} = this.props + if (!editable) return false + const projectAdmin = + user && + user.permissions && + user.permissions.isProjectAdmin(label.projectId) + return projectAdmin + } + + render () { + const {label, checked = false} = this.props + + // Used to avoid collision when label is rendered multiple times + const uniqueId = `${label.id}-${Date.now().toString(36)}` + + return ( + + {this._isCheckable() && ( + + + + )} + + {this._isEditable() && ( + + + + + + + + )} + + ) + } +} + +const mapStateToProps = (state, ownProps) => { + return { + user: state.user + } +} + +const mapDispatchToProps = { + deleteLabel +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FeedLabel) diff --git a/lib/common/components/Loading.js b/lib/common/components/Loading.js index 0eae019f5..f7ffd80c4 100644 --- a/lib/common/components/Loading.js +++ b/lib/common/components/Loading.js @@ -4,10 +4,12 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' import { Row, Col } from 'react-bootstrap' +import type {Style} from '../../types' + type Props = { inline?: boolean, small?: boolean, - style?: {[string]: string | number} + style?: Style } export default class Loading extends Component { diff --git a/lib/common/components/MenuItem.js b/lib/common/components/MenuItem.js new file mode 100644 index 000000000..a0d3eb342 --- /dev/null +++ b/lib/common/components/MenuItem.js @@ -0,0 +1,28 @@ +// @flow + +import Icon from '@conveyal/woonerf/components/icon' +import * as React from 'react' +import {MenuItem as BsMenuItem} from 'react-bootstrap' + +/** + * Simple wrapper around Bootstrap's menu item to inject a checkmark if the item + * is selected. + */ +const MenuItem = ({children, selected, ...menuItemProps}: {children?: React.Node, selected?: boolean}) => ( + + {selected + ? + : null + } + {children} + +) + +export default MenuItem diff --git a/lib/common/components/SelectFileModal.js b/lib/common/components/SelectFileModal.js index 4cf59efc8..d83e6c29c 100644 --- a/lib/common/components/SelectFileModal.js +++ b/lib/common/components/SelectFileModal.js @@ -8,21 +8,23 @@ type Props = { body?: string, errorMessage?: string, onClose?: () => void, - onConfirm?: (any) => boolean, + onConfirm?: (any) => Promise | boolean, title?: string } type State = { body?: string, + disabled?: boolean, errorMessage?: string, onClose?: () => void, - onConfirm?: (any) => boolean, + onConfirm?: (any) => Promise | boolean, showModal: boolean, title?: string } export default class SelectFileModal extends Component { state = { + disabled: false, errorMessage: '', onConfirm: (args: any) => false, showModal: false @@ -39,6 +41,7 @@ export default class SelectFileModal extends Component { if (props) { this.setState({ body: props.body, + disabled: false, errorMessage: props.errorMessage, onClose: props.onClose, onConfirm: props.onConfirm, @@ -46,13 +49,17 @@ export default class SelectFileModal extends Component { title: props.title }) } else { - this.setState({ showModal: true }) + this.setState({ disabled: false, showModal: true }) } } - ok = () => { + ok = async () => { const {errorMessage: propsErrorMessage, onConfirm: propsConfirm} = this.props const {errorMessage: stateErrorMessage, onConfirm: stateConfirm} = this.state + + // disable buttons while "loading" response + this.setState({disabled: true}) + if (!propsConfirm && !stateConfirm) { return this.close() } @@ -61,23 +68,23 @@ export default class SelectFileModal extends Component { const files = (node && node.files) ? node.files : [] if (propsConfirm) { - if (propsConfirm(files)) { + if (await propsConfirm(files)) { this.close() } else { - this.setState({errorMessage: propsErrorMessage || stateErrorMessage}) + this.setState({disabled: false, errorMessage: propsErrorMessage || stateErrorMessage}) } } else if (stateConfirm) { if (stateConfirm(files)) { this.close() } else { - this.setState({errorMessage: propsErrorMessage || stateErrorMessage}) + this.setState({disabled: false, errorMessage: propsErrorMessage || stateErrorMessage}) } } } render () { const {Body, Footer, Header, Title} = Modal - const {errorMessage} = this.state + const {disabled, errorMessage} = this.state return (
@@ -94,8 +101,8 @@ export default class SelectFileModal extends Component {
- - + +
) diff --git a/lib/common/constants/index.js b/lib/common/constants/index.js index e862c9363..0c98fb832 100644 --- a/lib/common/constants/index.js +++ b/lib/common/constants/index.js @@ -11,10 +11,28 @@ export const DEFAULT_LOGO = 'https://d2tyb7byn1fef9.cloudfront.net/ibi_group_bla export const DEFAULT_LOGO_SMALL = 'https://d2tyb7byn1fef9.cloudfront.net/ibi_group-128x128.png' export const DEFAULT_TITLE = 'Data Tools' +export const AUTO_DEPLOY_TYPES = Object.freeze({ + ON_FEED_FETCH: 'ON_FEED_FETCH', + ON_PROCESS_FEED: 'ON_PROCESS_FEED' +}) + +export const FETCH_FREQUENCIES = Object.freeze({ + MINUTES: 'MINUTES', + HOURS: 'HOURS', + DAYS: 'DAYS' +}) + +export const FREQUENCY_INTERVALS = Object.freeze({ + [FETCH_FREQUENCIES.MINUTES]: [5, 10, 15, 30], + [FETCH_FREQUENCIES.HOURS]: [1, 6, 12], + [FETCH_FREQUENCIES.DAYS]: [1, 2, 7, 14] +}) + export const RETRIEVAL_METHODS = Object.freeze({ MANUALLY_UPLOADED: 'MANUALLY_UPLOADED', FETCHED_AUTOMATICALLY: 'FETCHED_AUTOMATICALLY', PRODUCED_IN_HOUSE: 'PRODUCED_IN_HOUSE', + PRODUCED_IN_HOUSE_GTFS_PLUS: 'PRODUCED_IN_HOUSE_GTFS_PLUS', SERVICE_PERIOD_MERGE: 'SERVICE_PERIOD_MERGE', REGIONAL_MERGE: 'REGIONAL_MERGE', VERSION_CLONE: 'VERSION_CLONE' @@ -22,6 +40,7 @@ export const RETRIEVAL_METHODS = Object.freeze({ export const FEED_TRANSFORMATION_TYPES = Object.freeze({ DELETE_RECORDS: 'DeleteRecordsTransformation', - REPLACE_FILE_FROM_VERSION: 'ReplaceFileFromVersionTransformation', - REPLACE_FILE_FROM_STRING: 'ReplaceFileFromStringTransformation' + NORMALIZE_FIELD: 'NormalizeFieldTransformation', + REPLACE_FILE_FROM_STRING: 'ReplaceFileFromStringTransformation', + REPLACE_FILE_FROM_VERSION: 'ReplaceFileFromVersionTransformation' }) diff --git a/lib/common/util/config.js b/lib/common/util/config.js index e0b8ee794..f03836402 100644 --- a/lib/common/util/config.js +++ b/lib/common/util/config.js @@ -97,7 +97,9 @@ function initializeConfig () { const languages: Array = [ // $FlowFixMe - assume file exists and make flow happy - require('../../../i18n/english.yml') + require('../../../i18n/english.yml'), + // $FlowFixMe - assume file exists and make flow happy + require('../../../i18n/polish.yml') // Add additional language files once translation files are available here. // E.g., require('../../../i18n/espanol.yml') ] @@ -119,8 +121,9 @@ function initializeConfig () { const languageId = window.localStorage.getItem('lang') ? window.localStorage.getItem('lang') : navigator.language - const active = languages.find(l => l._id === languageId) || - languages.find(l => l._id === 'en-US') + const active = languages.find( + l => l._id === languageId || languageId.startsWith(l._id) + ) || languages.find(l => l._id === 'en-US') if (!active) throw new Error('Language file is misconfigured!') // is an array containing all the matching modules config.messages = { diff --git a/lib/common/util/gtfs.js b/lib/common/util/gtfs.js index 456d42223..dec517ea4 100644 --- a/lib/common/util/gtfs.js +++ b/lib/common/util/gtfs.js @@ -1,6 +1,9 @@ // @flow import moment from 'moment' +import {decode as decodePolyline} from 'polyline' + +type GraphQLShape = {polyline: string, shape_id: string} /** * @param {number} seconds Seconds after midnight @@ -36,3 +39,16 @@ export function secondsAfterMidnightToHHMM (seconds: ?(number | string)): string export function humanizeSeconds (seconds: number): string { return moment.duration(seconds, 'seconds').humanize() } + +/** + * Array map function to decode a GraphQL encoded shape polyline. + */ +export function decodeShapePolylines (shape: GraphQLShape) { + return { + id: shape.shape_id, + // Decode polyline and coords divide by ten (gtfs-lib + // simplification level requires this). + latLngs: decodePolyline(shape.polyline) + .map(coords => ([coords[0] / 10, coords[1] / 10])) + } +} diff --git a/lib/common/util/permissions.js b/lib/common/util/permissions.js index 969235f6c..9dd2d4d97 100644 --- a/lib/common/util/permissions.js +++ b/lib/common/util/permissions.js @@ -43,7 +43,7 @@ export function checkEntitiesForFeeds ( /** * Checks whether it is possible for a user in this application to analyze - * projet deployments + * project deployments */ export function deploymentsEnabledAndAccessAllowedForProject ( project: ?Project, diff --git a/lib/common/util/util.js b/lib/common/util/util.js index 7839c6dc4..49ff1ee74 100644 --- a/lib/common/util/util.js +++ b/lib/common/util/util.js @@ -1,8 +1,15 @@ // @flow import gravatar from 'gravatar' +import camelCase from 'lodash/camelCase' -import type {Feed, FeedVersion, Project, SummarizedFeedVersion} from '../../types' +import {getComponentMessages} from '../../common/util/config' +import type { + Feed, + FeedVersion, + Project, + SummarizedFeedVersion +} from '../../types' export function defaultSorter (a: FeedVersion | Project | Feed, b: FeedVersion | Project | Feed): number { if (a.isCreating && !b.isCreating) return -1 @@ -13,7 +20,7 @@ export function defaultSorter (a: FeedVersion | Project | Feed, b: FeedVersion | } export function getAbbreviatedProjectName (project: Project): string { - return project && project.name.length > 16 + return project && project.name && project.name.length > 16 ? `${project.name.substr(0, 12)}...` : project ? project.name @@ -29,17 +36,13 @@ export function versionsSorter ( return 0 } +/** + * Obtains the displayed (localizable) text for the given feed retrieval method. + */ export function retrievalMethodString (method: string): string { - switch (method) { - case 'MANUALLY_UPLOADED': - return 'Manually Uploaded' - case 'FETCHED_AUTOMATICALLY': - return 'Fetched Automatically' - case 'PRODUCED_IN_HOUSE': - return 'Produced In-house' - default: - throw new Error('Unknown method') - } + // Get the retrieval method strings. + const messages = getComponentMessages('FeedSourceViewer') + return messages(`properties.retrievalMethod.${camelCase(method)}`) } export function generateUID (): string { diff --git a/lib/editor/actions/active.js b/lib/editor/actions/active.js index 49b74d68a..e4fbf88c5 100644 --- a/lib/editor/actions/active.js +++ b/lib/editor/actions/active.js @@ -4,12 +4,10 @@ import clone from 'lodash/cloneDeep' import {browserHistory} from 'react-router' import {createAction, type ActionType} from 'redux-actions' -import {createVoidPayloadAction, secureFetch} from '../../common/actions' +import {fetchGraphQL, createVoidPayloadAction, secureFetch} from '../../common/actions' import {ENTITY} from '../constants' -import {newGtfsEntity, fetchBaseGtfs} from './editor' import {fetchFeedSourceAndProject} from '../../manager/actions/feeds' import {fetchGTFSEntities} from '../../manager/actions/versions' -import {saveTripPattern} from './tripPattern' import { getEditorNamespace, getTableById, @@ -18,9 +16,12 @@ import { subSubComponentList } from '../util/gtfs' import {getMapFromGtfsStrategy, entityIsNew} from '../util/objects' - import type {Entity, Feed} from '../../types' import type {dispatchFn, getStateFn, AppState} from '../../types/reducers' +import {getEntityGraphQLRoot} from '../../gtfs/util' + +import {lockEditorFeedSource, receiveBaseGtfs, removeEditorLock, newGtfsEntity, fetchBaseGtfs} from './editor' +import {saveTripPattern} from './tripPattern' export const clearGtfsContent = createVoidPayloadAction('CLEAR_GTFSEDITOR_CONTENT') const receivedNewEntity = createAction( @@ -190,6 +191,58 @@ function updateUrl ( } } +/** + * Fetches a list of routes from the backend for the editor. + * Requires a lock to function. + */ +export function fetchRouteEntities (feedSource: Feed) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + const { lock } = getState().editor.data + let lockCreated = false + // We need to get a lock if we do not already have one + if (!lock.sessionId) { + await dispatch(lockEditorFeedSource(feedSource.id)) + // Save that lock was created so it can be removed when we are done. + lockCreated = true + } + + const namespace = getEditorNamespace( + getState().editor.data.lock.feedId, + getState() + ) + if (!namespace) { + console.warn('namespace not defined, so not fetching gtfs entities') + return + } + + const graphQLRoot = getEntityGraphQLRoot('route') + if (!graphQLRoot) { + console.warn(`No graphql table for route.`) + } + + const query = ` + query entityQuery($namespace: String) { + feed(namespace: $namespace) { + ${graphQLRoot} (limit: -1) { + id + status + route_id + route_short_name + route_long_name + route_desc + } + } + } + ` + const variables = { namespace } + const data = await dispatch(fetchGraphQL({ query, variables })) + await dispatch(receiveBaseGtfs({...data, partial: true})) + if (lockCreated) { + await dispatch(removeEditorLock(feedSource.id, false, true)) + } + } +} + /** * This function is used to set the active components in the GTFS editor. * It also handles clearing GTFS content should the feed being edited change. @@ -331,14 +384,25 @@ export function saveEntity ( return } dispatch(savingActiveGtfsEntity()) - const notNew = !entityIsNew(entity) + // Add default vals for component + const defaults = {} + if (component === 'route') { + defaults.continuous_pickup = 1 // Default value for no continuous pickup + defaults.continuous_drop_off = 1 // Default value for no continuous drop off + } else if (component === 'feedinfo') { + defaults.default_lang = '' + defaults.feed_contact_url = '' + defaults.feed_contact_email = '' + } + const entityWithDefaults = {...defaults, ...(entity: any)} // add defaults, if any. + const notNew = !entityIsNew(entityWithDefaults) const method = notNew ? 'put' : 'post' - const idParam = notNew ? `/${entity.id || ''}` : '' + const idParam = notNew ? `/${entityWithDefaults.id || ''}` : '' const {sessionId} = getState().editor.data.lock const route = component === 'fare' ? 'fareattribute' : component const url = `/api/editor/secure/${route}${idParam}?feedId=${feedId}&sessionId=${sessionId || ''}` const mappingStrategy = getMapFromGtfsStrategy(component) - const data = mappingStrategy(entity) + const data = mappingStrategy(entityWithDefaults) return dispatch(secureFetch(url, method, data)) .then(res => res.json()) .then(savedEntity => { diff --git a/lib/editor/actions/editor.js b/lib/editor/actions/editor.js index 35d1a999a..c954dc12c 100644 --- a/lib/editor/actions/editor.js +++ b/lib/editor/actions/editor.js @@ -6,9 +6,13 @@ import moment from 'moment' import qs from 'qs' import {createAction, type ActionType} from 'redux-actions' -import {createVoidPayloadAction, fetchGraphQL, secureFetch} from '../../common/actions' +import { + createVoidPayloadAction, + fetchGraphQL, + postFormData, + secureFetch +} from '../../common/actions' import {generateUID} from '../../common/util/util' -import {clearGtfsContent, saveActiveGtfsEntity, setActiveGtfsEntity} from './active' import {ENTITY} from '../constants' import { generateNullProps, @@ -17,7 +21,6 @@ import { getTableById } from '../util/gtfs' import {fetchGTFSEntities} from '../../manager/actions/versions' - import type { dispatchFn, getStateFn, @@ -26,13 +29,15 @@ import type { LockState } from '../../types/reducers' +import {clearGtfsContent, saveActiveGtfsEntity, setActiveGtfsEntity} from './active' + export const updateEntitySort = createAction('UPDATE_ENTITY_SORT') const createGtfsEntity = createAction( 'CREATE_GTFS_ENTITY', (payload: { component: string, props: Object }) => payload ) -const receiveBaseGtfs = createAction( +export const receiveBaseGtfs = createAction( 'RECEIVE_BASE_GTFS', (payload: { feed: EditorTables }) => payload ) @@ -40,7 +45,7 @@ const setEditorCheckIn = createAction( 'SET_EDITOR_CHECK_IN', (payload: LockState) => payload ) -const showEditorModal = createVoidPayloadAction('SHOW_EDITOR_MODAL') +export const showEditorModal = createVoidPayloadAction('SHOW_EDITOR_MODAL') export type EditorActions = ActionType | ActionType | @@ -220,7 +225,8 @@ function startEditorLockMaintenance ( */ export function removeEditorLock ( feedId: ?string, - overwrite: ?boolean = false + overwrite: ?boolean = false, + keepEditorContent: ?boolean = false ): any { return function (dispatch: dispatchFn, getState: getStateFn) { if (!feedId) { @@ -257,7 +263,7 @@ export function removeEditorLock ( // successful. return false } - } else { + } else if (!keepEditorContent) { // Otherwise, the intention is to exit the editor. Clear all GTFS // content. This must be done finally so as not to interfere with the // lock removal. @@ -327,16 +333,10 @@ export function uploadBrandingAsset ( file: File ) { return function (dispatch: dispatchFn, getState: getStateFn) { - if (!file) { - console.warn('No file to upload!') - return - } - var data = new window.FormData() - data.append('file', file) const {sessionId} = getState().editor.data.lock const url = `/api/editor/secure/${component}/${entityId}/uploadbranding?feedId=${feedId}&sessionId=${sessionId || ''}` // Server handles associating branding asset entity's field - return dispatch(secureFetch(url, 'post', data, false, false)) + return dispatch(postFormData(url, file)) .then(res => res.json()) .then(r => { const namespace = getEditorNamespace(feedId, getState()) @@ -384,18 +384,21 @@ export function fetchBaseGtfs ({ feed_version default_route_color default_route_type + default_lang + feed_contact_url + feed_contact_email } - agency { + agency (limit: -1) { id agency_id agency_name } - calendar { + calendar (limit: -1) { id service_id description } - fares { + fares (limit: -1) { id fare_id } diff --git a/lib/editor/actions/map/index.js b/lib/editor/actions/map/index.js index 664ffad12..f79304923 100644 --- a/lib/editor/actions/map/index.js +++ b/lib/editor/actions/map/index.js @@ -19,7 +19,6 @@ import { newControlPoint, shapePointsToSimpleCoordinates } from '../../util/map' - import type {Feed, LatLng, Pattern, ControlPoint} from '../../../types' import type {dispatchFn, getStateFn} from '../../../types/reducers' @@ -163,8 +162,9 @@ export function handleControlPointDrag ( patternCoordinates: any ) { return function (dispatch: dispatchFn, getState: getStateFn) { - const {currentDragId, followStreets} = getState().editor.editSettings.present + const {avoidMotorways, currentDragId, followStreets} = getState().editor.editSettings.present recalculateShape({ + avoidMotorways, controlPoints, defaultToStraightLine: false, dragId: currentDragId, @@ -204,8 +204,9 @@ export function handleControlPointDragEnd ( dispatch(controlPointDragOrEnd()) // recalculate shape for final position - const {followStreets} = getState().editor.editSettings.present + const {avoidMotorways, followStreets} = getState().editor.editSettings.present recalculateShape({ + avoidMotorways, controlPoints, defaultToStraightLine: false, editType: 'update', @@ -243,11 +244,12 @@ export function handleControlPointDragStart (controlPoint: ControlPoint) { export function removeControlPoint (controlPoints: Array, index: number, pattern: Pattern, patternCoordinates: any) { return async function (dispatch: dispatchFn, getState: getStateFn) { - const {followStreets} = getState().editor.editSettings.present + const {avoidMotorways, followStreets} = getState().editor.editSettings.present const { coordinates, updatedControlPoints } = await recalculateShape({ + avoidMotorways, controlPoints, editType: 'delete', index, diff --git a/lib/editor/actions/map/stopStrategies.js b/lib/editor/actions/map/stopStrategies.js index 33f4dcc88..bc19e3236 100644 --- a/lib/editor/actions/map/stopStrategies.js +++ b/lib/editor/actions/map/stopStrategies.js @@ -32,7 +32,6 @@ import { constructPoint } from '../../util/map' import {coordinatesFromShapePoints} from '../../util/objects' - import type {ControlPoint, GtfsStop, LatLng, Pattern} from '../../../types' import type {dispatchFn, getStateFn} from '../../../types/reducers' @@ -223,12 +222,12 @@ export function addStopAtInterval (latlng: LatLng, activePattern: Pattern, contr export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?number) { return async function (dispatch: dispatchFn, getState: getStateFn) { const {data, editSettings} = getState().editor - const {followStreets} = editSettings.present + const {avoidMotorways, followStreets} = editSettings.present const {patternStops: currentPatternStops, shapePoints} = pattern const patternStops = clone(currentPatternStops) const {controlPoints, patternSegments} = getControlPoints(getState()) const patternLine = lineString(coordinatesFromShapePoints(shapePoints)) - const hasShapePoints = shapePoints && shapePoints.length > 2 + const hasShapePoints = shapePoints && shapePoints.length > 1 const newStop = stopToPatternStop( stop, (typeof index === 'undefined' || index === null) @@ -273,7 +272,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num } const points = [previousStop, stop] .map((stop, index) => ({lng: stop.stop_lon, lat: stop.stop_lat})) - const patternSegments = await getPolyline(points, true) + const patternSegments = await getPolyline(points, true, avoidMotorways) // Update pattern stops and geometry. const controlPoints = controlPointsFromSegments(patternStops, patternSegments) dispatch(updatePatternGeometry({controlPoints, patternSegments})) @@ -332,6 +331,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num let result try { result = await recalculateShape({ + avoidMotorways, controlPoints: clonedControlPoints, defaultToStraightLine: false, editType: 'update', @@ -372,6 +372,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num clonedPatternSegments.splice(0, 0, []) try { result = await recalculateShape({ + avoidMotorways: avoidMotorways, controlPoints: clonedControlPoints, defaultToStraightLine: false, editType: 'update', @@ -405,12 +406,12 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num */ function extendPatternToPoint (pattern, endPoint, newEndPoint, stop = null, splitInterval = 0) { return async function (dispatch: dispatchFn, getState: getStateFn) { - const {followStreets} = getState().editor.editSettings.present + const {avoidMotorways, followStreets} = getState().editor.editSettings.present const {controlPoints, patternSegments} = getControlPoints(getState()) const clonedControlPoints = clone(controlPoints) let newShape if (followStreets) { - newShape = await getPolyline([endPoint, newEndPoint]) + newShape = await getPolyline([endPoint, newEndPoint], false, avoidMotorways) } if (!newShape) { // Get single coordinate for straight line if polyline fails or if not @@ -502,10 +503,11 @@ export function removeStopFromPattern (pattern: Pattern, stop: GtfsStop, index: // If pattern has no shape points, don't attempt to refactor pattern shape console.log('pattern coordinates do not exist') } else { - const {followStreets} = getState().editor.editSettings.present + const {avoidMotorways, followStreets} = getState().editor.editSettings.present let result try { result = await recalculateShape({ + avoidMotorways, controlPoints: clonedControlPoints, editType: 'delete', index: cpIndex, diff --git a/lib/editor/actions/trip.js b/lib/editor/actions/trip.js index 8b25515c6..ddef25d49 100644 --- a/lib/editor/actions/trip.js +++ b/lib/editor/actions/trip.js @@ -1,5 +1,5 @@ // @flow - +import clone from 'lodash/cloneDeep' import {createAction, type ActionType} from 'redux-actions' import {snakeCaseKeys} from '../../common/util/map-keys' @@ -7,7 +7,6 @@ import {createVoidPayloadAction, fetchGraphQL, secureFetch} from '../../common/a import {setErrorMessage} from '../../manager/actions/status' import {entityIsNew} from '../util/objects' import {getEditorNamespace} from '../util/gtfs' - import type {Pattern, TimetableColumn, Trip} from '../../types' import type {dispatchFn, getStateFn, TripCounts} from '../../types/reducers' @@ -159,11 +158,21 @@ export function saveTripsForCalendar ( trips = trips.map(snakeCaseKeys) return Promise.all(trips.filter(t => t).map((trip, index) => { const tripExists = !entityIsNew(trip) && trip.id !== null + const tripCopy: any = clone((trip: any)) + // Add default value to continuous pickup if not provided + // Editing continuous pickup/drop off is not currently supported in the schedule editor + const defaults = { + continuous_pickup: 1, + continuous_drop_off: 1 + } + tripCopy.stop_times = tripCopy.stop_times.map((stopTime, index) => { + return {...defaults, ...(stopTime: any)} + }) const method = tripExists ? 'put' : 'post' const url = tripExists && trip.id ? `/api/editor/secure/trip/${trip.id}?feedId=${feedId}&sessionId=${sessionId}` : `/api/editor/secure/trip?feedId=${feedId}&sessionId=${sessionId}` - return dispatch(secureFetch(url, method, trip)) + return dispatch(secureFetch(url, method, tripCopy)) .then(res => res.json()) .catch(err => { console.warn(err) diff --git a/lib/editor/actions/tripPattern.js b/lib/editor/actions/tripPattern.js index 5c174ccfe..3234fd522 100644 --- a/lib/editor/actions/tripPattern.js +++ b/lib/editor/actions/tripPattern.js @@ -5,10 +5,12 @@ import {ActionCreators} from 'redux-undo' import {toast} from 'react-toastify' import {resetActiveGtfsEntity, savedGtfsEntity, updateActiveGtfsEntity, updateEditSetting} from './active' -import {createVoidPayloadAction, secureFetch} from '../../common/actions' +import {createVoidPayloadAction, fetchGraphQL, secureFetch} from '../../common/actions' import {snakeCaseKeys} from '../../common/util/map-keys' import {generateUID} from '../../common/util/util' -import {fetchGTFSEntities} from '../../manager/actions/versions' +import {showEditorModal} from './editor' +import {shapes} from '../../gtfs/util/graphql' +import {fetchGTFSEntities, receiveGTFSEntities} from '../../manager/actions/versions' import {fetchTripCounts} from './trip' import {getEditorNamespace} from '../util/gtfs' import {resequenceStops, resequenceShapePoints} from '../util/map' @@ -17,6 +19,7 @@ import {entityIsNew} from '../util/objects' import type {ControlPoint, Pattern, PatternStop} from '../../types' import type {dispatchFn, getStateFn} from '../../types/reducers' +const fetchingTripPatterns = createVoidPayloadAction('FETCHING_TRIP_PATTERNS') const savedTripPattern = createVoidPayloadAction('SAVED_TRIP_PATTERN') export const setActivePatternSegment = createAction( 'SET_ACTIVE_PATTERN_SEGMENT', @@ -118,8 +121,25 @@ export function togglePatternEditing () { */ export function fetchTripPatterns (feedId: string) { return function (dispatch: dispatchFn, getState: getStateFn) { + dispatch(fetchingTripPatterns()) const namespace = getEditorNamespace(feedId, getState()) - return dispatch(fetchGTFSEntities({namespace, type: 'pattern', editor: true})) + if (!namespace) { + console.error('Cannot fetch GTFS for undefined or null namespace') + dispatch(showEditorModal()) + return + } + const variables = {namespace} + return dispatch(fetchGraphQL({query: shapes, variables})) + .then(data => dispatch( + receiveGTFSEntities({ + namespace, + component: 'pattern', + data, + editor: true, + id: null, + replaceNew: true + }) + )) } } diff --git a/lib/editor/components/CreateSnapshotModal.js b/lib/editor/components/CreateSnapshotModal.js index efb9bc068..2c74f2d25 100644 --- a/lib/editor/components/CreateSnapshotModal.js +++ b/lib/editor/components/CreateSnapshotModal.js @@ -1,5 +1,6 @@ // @flow +import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' import { Button, @@ -11,23 +12,29 @@ import { } from 'react-bootstrap' import {connect} from 'react-redux' -import * as snapshotActions from '../actions/snapshots.js' +import * as activeActions from '../actions/active' +import * as snapshotActions from '../actions/snapshots' import {getComponentMessages} from '../../common/util/config' import {formatTimestamp} from '../../common/util/date-time' - -import type {Feed} from '../../types' -import type {AppState} from '../../types/reducers' - +import { getRouteStatusDict, ROUTE_STATUS_CODES } from '../util' +import {getEntityName} from '../util/gtfs' +import type {Feed, GtfsRoute} from '../../types' +import type {AppState, LockState} from '../../types/reducers' type Props = { createSnapshot: typeof snapshotActions.createSnapshot, - feedSource: Feed -} + feedSource: Feed, + fetchRouteEntities: typeof activeActions.fetchRouteEntities, + lock: LockState, + routes: Array +}; type State = { comment: ?string, + confirmPublishWithUnapproved: boolean, + loading: boolean, name: ?string, publishNewVersion: boolean, - showModal: boolean + showModal: boolean, } function getDefaultState () { @@ -35,7 +42,9 @@ function getDefaultState () { comment: null, name: formatTimestamp(), publishNewVersion: false, - showModal: false + confirmPublishWithUnapproved: false, + showModal: false, + loading: false } } @@ -48,6 +57,10 @@ class CreateSnapshotModal extends Component { this.setState({[e.target.name]: e.target.checked}) } + _onTogglePublishUnapproved = (e: SyntheticInputEvent) => { + this.setState({[e.target.name]: e.target.checked}) + } + _onChange = (e: SyntheticInputEvent) => { this.setState({[e.target.name]: e.target.value}) } @@ -56,7 +69,21 @@ class CreateSnapshotModal extends Component { this.setState(getDefaultState()) } + componentDidUpdate (prevProps) { + const {routes} = this.props + // assume that when the routes gets updated, it means that they have been loaded into the store + if (routes !== prevProps.routes) { + this.setState({ + loading: false + }) + } + } + open () { + const {fetchRouteEntities, feedSource} = this.props + this.setState({loading: true}) + fetchRouteEntities(feedSource) + this.setState({ showModal: true }) @@ -72,7 +99,18 @@ class CreateSnapshotModal extends Component { render () { const {Body, Footer, Header, Title} = Modal - const {comment, name, publishNewVersion, showModal} = this.state + const { + comment, + confirmPublishWithUnapproved, + loading, + name, + publishNewVersion, + showModal + } = this.state + const {routes} = this.props + const routeStatusDict = getRouteStatusDict() + const unapprovedRoutes = routes.filter(route => route.hasOwnProperty('status') && route.status !== ROUTE_STATUS_CODES.APPROVED) + return (
@@ -112,11 +150,35 @@ class CreateSnapshotModal extends Component { {this.messages('fields.publishNewVersion.label')} + {loading && } + {unapprovedRoutes.length > 0 && +
+

{this.messages('unapprovedRoutesHeader')}

+

{this.messages('unapprovedRoutesDesc')}

+
    + { + unapprovedRoutes.map(route => ( +
  • {getEntityName(route)}: {routeStatusDict[parseInt(route.status)]}
  • + )) + } +
+ + + {this.messages('fields.confirmPublishWithUnapproved.label')} + + +
+ }
} diff --git a/lib/editor/components/FeedInfoPanel.js b/lib/editor/components/FeedInfoPanel.js index 2be0bf3a4..82bd0815a 100644 --- a/lib/editor/components/FeedInfoPanel.js +++ b/lib/editor/components/FeedInfoPanel.js @@ -11,14 +11,14 @@ import * as snapshotActions from '../actions/snapshots' import SelectFileModal from '../../common/components/SelectFileModal.js' import {getComponentMessages} from '../../common/util/config' import {isValidZipFile} from '../../common/util/util' -import CreateSnapshotModal from './CreateSnapshotModal' import { GTFS_ICONS } from '../util/ui' import {componentToText} from '../util/objects' import {ENTITY} from '../constants' - import type {Props as ContainerProps} from '../containers/ActiveFeedInfoPanel' import type {Feed, FeedInfo, Project} from '../../types' +import CreateSnapshotModal from './CreateSnapshotModal' + type Props = ContainerProps & { displayRoutesShapefile: typeof mapActions.displayRoutesShapefile, feedInfo: FeedInfo, diff --git a/lib/editor/components/MinuteSecondInput.js b/lib/editor/components/MinuteSecondInput.js index 29a0d92c3..cd2133a35 100644 --- a/lib/editor/components/MinuteSecondInput.js +++ b/lib/editor/components/MinuteSecondInput.js @@ -8,11 +8,13 @@ import { convertMMSSStringToSeconds } from '../../common/util/date-time' +import type {Style} from '../../types' + type Props = { disabled?: boolean, onChange: number => void, seconds: number, - style?: {[string]: string | number} + style?: Style } type State = { diff --git a/lib/editor/components/RouteTypeSelect.js b/lib/editor/components/RouteTypeSelect.js new file mode 100644 index 000000000..b9d1fbf8d --- /dev/null +++ b/lib/editor/components/RouteTypeSelect.js @@ -0,0 +1,133 @@ +// @flow + +import React, { Component } from 'react' +import DropdownTreeSelect from 'react-dropdown-tree-select' + +import type { GtfsSpecField } from '../../types' + +type Props = { + field: GtfsSpecField, + onRouteTypeChange: (any, any) => void, + routeType: number +} + +type State = { + data: any +} + +type NumericalOption = { + disabled?: boolean, + text: string, + value: number +} + +function convertValueToNumber ({ text, value }: { text: string, value: string }): NumericalOption { + return { + text, + value: parseInt(value, 10) + } +} +/** + * Determines whether a route is standard or not. + */ +function isStandardRouteType ({ value }: { value: number }) { + return value < 100 +} + +/** + * Creates the route types tree structure for the route type selector. + */ +function getRouteTypeOptions (field: GtfsSpecField, routeType: number) { + // Convert option values into numbers. + const routeTypes = (field.options || []).map(convertValueToNumber) + + const standardRouteTypes = routeTypes.filter(isStandardRouteType) + const extendedRouteTypes = routeTypes.filter(opt => !isStandardRouteType(opt)) + // Show unknown value as invalid and prevent user from picking that value. + const unknownRouteTypes = [] + if (!routeTypes.find(opt => opt.value === routeType)) { + unknownRouteTypes.push({ + disabled: true, + text: 'Invalid', + value: routeType + }) + } + + // Helper function that converts a field option to an entry for the tree selector. + // It is inline because it uses the routeType argument. + const toTreeOption = ({ disabled, text, value }: NumericalOption) => ({ + checked: value === routeType, + disabled, + label: `${text} (${value})`, + value + }) + + // Variant of the function above for standard route types + // that includes a data-test-id for e2e tests. + const toTreeOptionWithDataId = (opt: NumericalOption) => ({ + ...toTreeOption(opt), + dataset: { testId: `route-type-option-${opt.value}` } + }) + + // Display in this order: + // - non-standard/unknown route type used for this route, if applicable, + // - standard route types + // - extended route types, if configured. + return [ + ...unknownRouteTypes.map(toTreeOption), + ...standardRouteTypes.map(toTreeOptionWithDataId), + ...(extendedRouteTypes.length > 0 ? [{ + children: [ + // Group children by category (e.g. all 101-199 route types fall under 100-Railway). + // Get all the categories here. + ...extendedRouteTypes + .filter(opt => opt.value % 100 === 0) + .map(category => ({ + ...toTreeOption(category), + // Add the children for each category. + children: extendedRouteTypes + .filter( + opt => opt.value > category.value && opt.value < category.value + 100 + ) + .map(toTreeOption) + })) + ], + // Used for CSS no-pointer and font effects. + className: 'extended-values-node', + label: 'Extended GTFS Route Types' + }] : []) + ] +} + +/** + * Encapsulates a drop-down selector with a hierarchical (tree) list of choices, + * and filters out unnecessary prop changes to prevent flicker. + */ +export default class RouteTypeSelect extends Component { + constructor (props: Props) { + super(props) + this.state = { data: getRouteTypeOptions(props.field, props.routeType) } + } + + /** + * Prevent resetting of any expanded status of the tree hierarchy + * (datatools polls the backend every 10 seconds and that causes new props + * to be passed to the editor component). + */ + shouldComponentUpdate (nextProps: Props) { + return nextProps.routeType !== this.props.routeType && + nextProps.field !== this.props.field + } + + render () { + return ( + + ) + } +} diff --git a/lib/editor/components/VirtualizedEntitySelect.js b/lib/editor/components/VirtualizedEntitySelect.js index ffc388a0b..f72a9558b 100644 --- a/lib/editor/components/VirtualizedEntitySelect.js +++ b/lib/editor/components/VirtualizedEntitySelect.js @@ -5,7 +5,7 @@ import VirtualizedSelect from 'react-virtualized-select' import {getEntityName} from '../util/gtfs' -import type {Entity} from '../../types' +import type {Entity, Style} from '../../types' export type EntityOption = { entity: Entity, @@ -20,7 +20,7 @@ type Props = { entityKey: string, onChange: any => void, optionRenderer?: Function, - style?: {[string]: number | string}, + style?: Style, value?: any } diff --git a/lib/editor/components/map/AddableStop.js b/lib/editor/components/map/AddableStop.js index f12c927e2..3b7311689 100644 --- a/lib/editor/components/map/AddableStop.js +++ b/lib/editor/components/map/AddableStop.js @@ -1,12 +1,11 @@ // @flow -import Icon from '@conveyal/woonerf/components/icon' import { divIcon } from 'leaflet' import React, {Component} from 'react' -import {Button, Dropdown, MenuItem} from 'react-bootstrap' import {Marker, Popup} from 'react-leaflet' import * as stopStrategiesActions from '../../actions/map/stopStrategies' +import AddPatternStopDropdown from '../pattern/AddPatternStopDropdown' import type {GtfsStop, Pattern} from '../../../types' @@ -28,6 +27,7 @@ export default class AddableStop extends Component { render () { const { activePattern, + addStopToPattern, stop } = this.props const color = 'blue' @@ -40,7 +40,6 @@ export default class AddableStop extends Component { className: '', iconSize: [24, 24] }) - // TODO: Refactor to share code with PatternStopButtons return ( {
{stopName}
- - - - - - Add to end (default) - - {activePattern.patternStops && activePattern.patternStops.map((stop, i) => { - const index = activePattern.patternStops.length - i - return ( - - {index === 1 ? 'Add to beginning' : `Insert as stop #${index}`} - - ) - })} - - +
diff --git a/lib/editor/components/map/EditorMapLayersControl.js b/lib/editor/components/map/EditorMapLayersControl.js index dc46fd805..8e83530e6 100644 --- a/lib/editor/components/map/EditorMapLayersControl.js +++ b/lib/editor/components/map/EditorMapLayersControl.js @@ -1,13 +1,12 @@ // @flow -import React, {Component} from 'react' -import {Browser} from 'leaflet' +import React, {PureComponent} from 'react' +import L from 'leaflet' import { - TileLayer, FeatureGroup, LayersControl, Polyline, - Tooltip + TileLayer } from 'react-leaflet' import {getUserMetadataProperty} from '../../../common/util/user' @@ -26,8 +25,9 @@ type Props = { type OverlayItem = {component: any, name: string} -export default class EditorMapLayersControl extends Component { +export default class EditorMapLayersControl extends PureComponent { render () { + const canvas = L.canvas() const { tripPatterns, stops, user } = this.props const { BaseLayer, Overlay } = LayersControl const OVERLAYS: Array = [ @@ -40,14 +40,12 @@ export default class EditorMapLayersControl extends Component { if (!tp.latLngs) return null return ( - - {tp.name} ({tp.route_id}) - - + renderer={canvas} + weight={2} /> ) }) : null @@ -78,7 +76,7 @@ export default class EditorMapLayersControl extends Component { name='Route layer' key='route-layer'> + url={`https://s3.amazonaws.com/datatools-nysdot/tiles/{z}_{x}_{y}${L.Browser.retina ? '@2x' : ''}.png`} /> } {OVERLAYS.map((overlay: OverlayItem, i: number) => ( diff --git a/lib/editor/components/map/pattern-debug-lines.js b/lib/editor/components/map/pattern-debug-lines.js index 99da76d6c..aa17ccce2 100644 --- a/lib/editor/components/map/pattern-debug-lines.js +++ b/lib/editor/components/map/pattern-debug-lines.js @@ -5,8 +5,8 @@ import {Polyline} from 'react-leaflet' import lineDistance from 'turf-line-distance' import lineString from 'turf-linestring' -import {POINT_TYPE} from '../../constants' -import {isValidPoint} from '../../util/map' +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,8 +20,6 @@ type Props = { stops: Array } -const DISTANCE_THRESHOLD = 50 - /** * This react-leaflet component draws connecting lines between a pattern * geometry's anchor points (that are associated with stops) and their @@ -44,33 +42,33 @@ export default class PatternDebugLines extends PureComponent { // i.e., whether the line should be rendered. .map((cp, index) => ({...cp, cpIndex: index})) // Filter out the user-added anchors - .filter(cp => cp.pointType === POINT_TYPE.STOP) + .filter(isValidStopControlPoint) // The remaining number should match the number of stops .map((cp, index) => { const {cpIndex, point, stopId} = cp - if (!isValidPoint(point)) { - return null - } + // If hiding inactive segments (and this control point is not along + // a visible segment), do not show debug line. if (editSettings.hideInactiveSegments && (cpIndex > patternSegment + 1 || cpIndex < patternSegment - 1)) { return null } const patternStopIsActive = patternStop.index === index // Do not render if some other pattern stop is active - // $FlowFixMe - if ((patternStop.index || patternStop.index === 0) && !patternStopIsActive) { + if (typeof patternStop.index === 'number' && !patternStopIsActive) { return null } const {coordinates: cpCoord} = point.geometry + // Find stop entity for control point. const stop = stops.find(s => s.stop_id === stopId) if (!stop) { - // console.warn(`Could not find stop for pattern stop index=${index} patternStop#stopId=${stopId}`) + // If no stop entity found, do not attempt to draw a line to the + // missing stop. return null } const coordinates = [[cpCoord[1], cpCoord[0]], [stop.stop_lat, stop.stop_lon]] const distance: number = lineDistance(lineString(coordinates), 'meters') - const distanceGreaterThanThreshold = distance > DISTANCE_THRESHOLD + const distanceGreaterThanThreshold = distance > PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS if (distanceGreaterThanThreshold) { - console.warn(`Distance from pattern stop index=${index} to projected point is greater than ${DISTANCE_THRESHOLD} (${distance}).`) + console.warn(`Distance from pattern stop index=${index} to projected point is greater than ${PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} (${distance}).`) } return ( { + _addStop = (index?: number) => { + const {activePattern, addStopToPattern, stop} = this.props + addStopToPattern(activePattern, stop, index) + } + + _matchesStopAtIndex = (index: number) => { + const {activePattern, stop} = this.props + const patternStopAtIndex = activePattern.patternStops[index] + return patternStopAtIndex && patternStopAtIndex.stopId === stop.stop_id + } + + _onAddToEnd = () => this._addStop() + + _onSelectStop = (key: number) => this._addStop(key) + + render () { + const {activePattern, index, label, size, style} = this.props + const {patternStops} = activePattern + const lastIndex = patternStops.length - 1 + // Check that first/last stop is not already set to this stop. + let addToEndDisabled = this._matchesStopAtIndex(lastIndex) + let addToBeginningDisabled = this._matchesStopAtIndex(0) + // Also, disable end/beginning if the current pattern stop being viewed + // occupies one of these positions. + if (typeof index === 'number') { + addToEndDisabled = addToEndDisabled || index >= lastIndex + addToBeginningDisabled = addToBeginningDisabled || index === 0 + } + return ( + + + + + + Add to end (default) + + {activePattern.patternStops && activePattern.patternStops.map((s, i) => { + // addIndex is in "reverse" order. For example: + // - 5 pattern stops + // - rendering MenuItem 'Insert at stop #4' + // - addIndex = 3 = 5 (stops) - 1 (i) - 1 + // - nextIndex = 4 + // - previousIndex = 2 + const addIndex = activePattern.patternStops.length - i - 1 + const nextIndex = addIndex + 1 + const previousIndex = addIndex - 1 + let disableAdjacent = false + // If showing the dropdown for the currently selected pattern stop, + // do not allow adding as an adjacent stop (a pattern should not + // visit the same stop consecutively). + if (typeof index === 'number') { + disableAdjacent = index > previousIndex && index < nextIndex + } + // Disable adding stop to current position or directly before + // current position. nextIndex is OK because inserting the stop at + // addIndex would move the current stop at addIndex into the + // nextIndex position, which would serve as a buffer (and avoid + // having the same stop in consecutive positions). + const addAtIndexDisabled = disableAdjacent || + this._matchesStopAtIndex(previousIndex) || + this._matchesStopAtIndex(addIndex) + // Skip MenuItem if index is the same as the currently selected + // pattern stop index or it's the zeroth addIndex (Add to + // beginning handles this case). + if (index === addIndex || addIndex === 0) { + return null + } + return ( + + Insert as stop #{addIndex + 1} + + ) + })} + + Add to beginning + + + + ) + } +} diff --git a/lib/editor/components/pattern/EditSettings.js b/lib/editor/components/pattern/EditSettings.js index 47e598ecf..d353495fe 100644 --- a/lib/editor/components/pattern/EditSettings.js +++ b/lib/editor/components/pattern/EditSettings.js @@ -7,7 +7,6 @@ import Rcslider from 'rc-slider' import {updateEditSetting} from '../../actions/active' import {CLICK_OPTIONS} from '../../util' import toSentenceCase from '../../../common/util/to-sentence-case' - import type {EditSettingsState} from '../../../types/reducers' type Props = { @@ -60,11 +59,13 @@ export default class EditSettings extends Component { const {editSettings, patternSegment, updateEditSetting} = this.props const { editGeometry, + followStreets, onMapClick, stopInterval } = editSettings const SETTINGS = [ {type: 'followStreets', label: 'Snap to streets'}, + {type: 'avoidMotorways', label: 'Avoid highways in routing'}, {type: 'hideStopHandles', label: 'Hide stop handles'}, {type: 'hideInactiveSegments', label: 'Hide inactive segments'}, {type: 'showStops', label: 'Show stops'}, @@ -80,7 +81,10 @@ export default class EditSettings extends Component { key={s.type} // Disable hide inactive segments if no segment is selected (hiding in // this state would cause the entire shape to disappear). - disabled={s.type === 'hideInactiveSegments' && noSegmentIsActive} + disabled={ + (s.type === 'hideInactiveSegments' && noSegmentIsActive) || + (s.type === 'avoidMotorways' && !followStreets) + } name={s.type} style={{margin: '3px 0'}} onChange={this._onCheckboxChange}> diff --git a/lib/editor/components/pattern/EditShapePanel.js b/lib/editor/components/pattern/EditShapePanel.js index 2b67a033d..9f72213de 100644 --- a/lib/editor/components/pattern/EditShapePanel.js +++ b/lib/editor/components/pattern/EditShapePanel.js @@ -2,27 +2,30 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' -import {Button, ButtonGroup, ButtonToolbar, OverlayTrigger, Tooltip} from 'react-bootstrap' +import {Alert, Button, ButtonGroup, ButtonToolbar, OverlayTrigger, Tooltip} from 'react-bootstrap' import ll from '@conveyal/lonlat' import numeral from 'numeral' +import lineDistance from 'turf-line-distance' +import lineString from 'turf-linestring' import * as activeActions from '../../actions/active' import * as mapActions from '../../actions/map' -import {ARROW_MAGENTA} from '../../constants' +import {ARROW_MAGENTA, PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} from '../../constants' import * as tripPatternActions from '../../actions/tripPattern' import OptionButton from '../../../common/components/OptionButton' -import EditSettings from './EditSettings' import * as statusActions from '../../../manager/actions/status' import {polyline as getPolyline} from '../../../scenario-editor/utils/valhalla' import { controlPointsFromSegments, generateControlPointsFromPatternStops, - getPatternDistance + getPatternDistance, + isValidStopControlPoint } from '../../util/map' - import type {ControlPoint, LatLng, Pattern, GtfsStop} from '../../../types' import type {EditSettingsUndoState} from '../../../types/reducers' +import EditSettings from './EditSettings' + type Props = { activePattern: Pattern, controlPoints: Array, @@ -46,10 +49,10 @@ export default class EditShapePanel extends Component { * Construct new pattern geometry from the pattern stop locations. */ async drawPatternFromStops (pattern: Pattern, stopsCoordinates: Array, followStreets: boolean): Promise { - const {saveActiveGtfsEntity, setErrorMessage, updatePatternGeometry} = this.props + const {editSettings, saveActiveGtfsEntity, setErrorMessage, updatePatternGeometry} = this.props let patternSegments = [] if (followStreets) { - patternSegments = await getPolyline(stopsCoordinates, true) + patternSegments = await getPolyline(stopsCoordinates, true, editSettings.present.avoidMotorways) } else { // Construct straight-line segments using stop coordinates stopsCoordinates @@ -139,6 +142,42 @@ export default class EditShapePanel extends Component { }) } + /** + * Checks the control points for stop control points that are located too far + * from the actual stop location. This is used to give instructions to the + * user on resolving the issue. + */ + _getPatternStopsWithShapeIssues = () => { + const {controlPoints, stops} = this.props + return controlPoints + .filter(isValidStopControlPoint) + .map((controlPoint, index) => { + const {point, stopId} = controlPoint + let exceedsThreshold = false + const {coordinates: cpCoord} = point.geometry + // Find stop entity for control point. + const stop = stops.find(s => s.stop_id === stopId) + if (!stop) { + // If no stop entity found, do not attempt to draw a line to the + // missing stop. + return {controlPoint, index, stop: null, distance: 0, exceedsThreshold} + } + const coordinates = [[cpCoord[1], cpCoord[0]], [stop.stop_lat, stop.stop_lon]] + const distance: number = lineDistance(lineString(coordinates), 'meters') + exceedsThreshold = distance > PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS + return { + controlPoint, + distance, + exceedsThreshold, + index, + stop + } + }) + // TODO: This can be removed if at some point we need to show stops where + // the distance threshold is not exceeded. + .filter(item => item.exceedsThreshold) + } + _beginEditing = () => { const {togglePatternEditing} = this.props togglePatternEditing() @@ -188,6 +227,7 @@ export default class EditShapePanel extends Component { const nextSegment = (!patternSegment && patternSegment !== 0) ? 0 : patternSegment + 1 + const patternStopsWithShapeIssues = this._getPatternStopsWithShapeIssues() return (

@@ -217,6 +257,54 @@ export default class EditShapePanel extends Component { }

+ {patternStopsWithShapeIssues.length > 0 + ? +

Pattern stop snapping issue

+
    + {patternStopsWithShapeIssues + .map(item => { + const {distance, index, stop} = item + if (!stop) return null + const roundedDist = Math.round(distance * 100) / 100 + return ( +
  • + #{index + 1} {stop.stop_name}{' '} + + {roundedDist} m + +
  • + ) + }) + } +
+

+ The stop(s) listed above are located + too far (max = {PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS}{' '} + meters) from the pattern shape. +

+

+ This can be resolved by: +

    +
  1. + moving the stop itself closer to the street's edge; +
  2. +
  3. + changing where the stop is "snapped" to the shape: click{' '} + Edit pattern geometry, uncheck{' '} + Hide stop handles, and move the stop handle + closer to the stop. Checking Hide inactive segments{' '} + can help isolate the problematic stop handle; or +
  4. +
  5. + regenerating the shape from existing stops: click{' '} + From stops. +
  6. +
+

+
+ : null + } + {editSettings.editGeometry ?
diff --git a/lib/editor/components/pattern/PatternStopButtons.js b/lib/editor/components/pattern/PatternStopButtons.js index 293fcf14f..3aa939aa2 100644 --- a/lib/editor/components/pattern/PatternStopButtons.js +++ b/lib/editor/components/pattern/PatternStopButtons.js @@ -2,13 +2,14 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' -import { Button, Dropdown, OverlayTrigger, Tooltip, ButtonGroup, MenuItem } from 'react-bootstrap' +import { Button, OverlayTrigger, Tooltip, ButtonGroup } from 'react-bootstrap' import * as activeActions from '../../actions/active' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' +import AddPatternStopDropdown from './AddPatternStopDropdown' -import type {Feed, GtfsStop, Pattern, PatternStop} from '../../../types' +import type {Feed, GtfsStop, Pattern, PatternStop, Style} from '../../../types' type Props = { activePattern: Pattern, @@ -23,7 +24,7 @@ type Props = { setActiveStop: typeof tripPatternActions.setActiveStop, size: string, stop: GtfsStop, - style?: {[string]: number | string}, + style?: Style, updatePatternStops: typeof tripPatternActions.updatePatternStops } @@ -44,7 +45,13 @@ export default class PatternStopButtons extends Component { _onClickRemove = () => { const {activePattern, index, removeStopFromPattern, stop} = this.props - removeStopFromPattern(activePattern, stop, index) + if ( + window.confirm( + `Are you sure you would like to remove ${stop.stop_name} (${stop.stop_id}) as pattern stop #${index + 1}?` + ) + ) { + removeStopFromPattern(activePattern, stop, index) + } } _onClickSave = () => this.props.saveActiveGtfsEntity('trippattern') @@ -55,11 +62,16 @@ export default class PatternStopButtons extends Component { } render () { - const {stop, index, activePattern, patternEdited, style, size} = this.props - const {patternStops} = activePattern - const lastIndex = patternStops.length - 1 - const addToEndDisabled = index >= lastIndex || patternStops[lastIndex].stopId === stop.id - const addToBeginningDisabled = index === 0 || patternStops[0].stopId === stop.id + const { + activePattern, + addStopToPattern, + index, + patternEdited, + patternStop, + size, + stop, + style + } = this.props return ( { onClick={this._onClickSave}> - Edit stop}> + Edit stop} + > - Remove from pattern}> + Remove stop from pattern + } + > - - - - - - Add to end (default) - - {activePattern.patternStops && activePattern.patternStops.map((s, i) => { - // addIndex is in "reverse" order - const addIndex = activePattern.patternStops.length - i - const addAtIndexDisabled = (index >= addIndex - 2 && index < addIndex) || - (patternStops[addIndex - 2] && patternStops[addIndex - 2].stopId === stop.id) || - (patternStops[addIndex - 1] && patternStops[addIndex - 1].stopId === stop.id) - // (patternStops[addIndex + 1] && patternStops[addIndex + 1].stopId === stop.id) - // skip MenuItem index is the same as the pattern stop index - if (index === addIndex - 1 || addIndex === 1) { - return null - } - // disable adding stop to current position or directly before/after current position - return ( - - {`Insert as stop #${addIndex}`} - - ) - })} - - Add to beginning - - - + ) } diff --git a/lib/editor/components/pattern/PatternStopCard.js b/lib/editor/components/pattern/PatternStopCard.js index 4ae24cdf5..4b632a7c7 100644 --- a/lib/editor/components/pattern/PatternStopCard.js +++ b/lib/editor/components/pattern/PatternStopCard.js @@ -1,20 +1,29 @@ // @flow import Icon from '@conveyal/woonerf/components/icon' -import React, {Component} from 'react' +import clone from 'lodash/cloneDeep' +import React, { Component } from 'react' import { DragSource, DropTarget } from 'react-dnd' -import { Row, Col, Collapse, FormGroup, ControlLabel, Checkbox } from 'react-bootstrap' +import { + Checkbox, + Col, + Collapse, + ControlLabel, + FormControl, + FormGroup, + Row +} from 'react-bootstrap' import * as activeActions from '../../actions/active' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' import {getEntityName, getAbbreviatedStopName} from '../../util/gtfs' import MinuteSecondInput from '../MinuteSecondInput' +import type {Feed, Pattern, PatternStop} from '../../../types' + import NormalizeStopTimesTip from './NormalizeStopTimesTip' import PatternStopButtons from './PatternStopButtons' -import type {Feed, Pattern, PatternStop} from '../../../types' - type Props = { active: boolean, activePattern: Pattern, @@ -42,6 +51,79 @@ type Props = { updatePatternStops: typeof tripPatternActions.updatePatternStops } +type PickupDropoffSelectProps = { + activePattern: Pattern, + controlLabel: string, + onChange: (evt: SyntheticInputEvent) => void, + selectType: string, + shouldHaveDisabledOption: boolean, + title: string, + value: string | number +} + +type State = { + initialDwellTime: number, + initialTravelTime: number, + update: boolean +} + +const pickupDropoffOptions = [ + { + value: 0, + text: 'Available (0)' + }, + { + value: 1, + text: 'Not available (1)' + }, + { + value: 2, + text: 'Must phone agency to arrange (2)' + }, + { + value: 3, + text: 'Must coordinate with driver to arrange (3)' + } +] + +/** renders the form control drop downs for dropOff/Pick up and also continuous */ +const PickupDropoffSelect = (props: PickupDropoffSelectProps) => { + const { + activePattern, + controlLabel, + onChange, + selectType, + shouldHaveDisabledOption, + title, + value + } = props + const hasShapeId = activePattern.shapeId === null + return ( + + + {controlLabel} + + + {pickupDropoffOptions.map(o => ( + + ))} + + + ) +} + const cardSource = { beginDrag (props: Props) { return { @@ -174,12 +256,6 @@ class PatternStopCard extends Component { } } -type State = { - initialDwellTime: number, - initialTravelTime: number, - update: boolean -} - class PatternStopContents extends Component { componentWillMount () { this.setState({ @@ -246,12 +322,25 @@ class PatternStopContents extends Component { updatePatternStops(activePattern, patternStops) } + _onPickupOrDropOffChange = (evt: SyntheticInputEvent) => { + const selectedOptionValue: number = parseInt(evt.target.value, 10) + const {activePattern, index, updatePatternStops} = this.props + const patternStops = [...activePattern.patternStops] + + const newPatternStop = clone(patternStops[index]) + newPatternStop[evt.target.id] = selectedOptionValue + patternStops[index] = newPatternStop + this.setState({update: true}) + updatePatternStops(activePattern, patternStops) + } + render () { - const {active, patternEdited, patternStop} = this.props + const {active, activePattern, patternEdited, patternStop} = this.props // This component has a special shouldComponentUpdate to ensure that state // is not overwritten with new props, so use state.update to check edited // state. const isEdited = patternEdited || this.state.update + let innerDiv if (active) { innerDiv =
@@ -301,6 +390,55 @@ class PatternStopContents extends Component { + {/* Pickup and drop off type selectors */} + + + + + + + + + + + + + + + +
} diff --git a/lib/editor/components/pattern/PatternStopsPanel.js b/lib/editor/components/pattern/PatternStopsPanel.js index 320fd8f4d..1192ecda8 100644 --- a/lib/editor/components/pattern/PatternStopsPanel.js +++ b/lib/editor/components/pattern/PatternStopsPanel.js @@ -8,9 +8,11 @@ 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' @@ -34,19 +36,26 @@ type Props = { stops: Array, updateActiveGtfsEntity: typeof activeActions.updateActiveGtfsEntity, updateEditSetting: typeof activeActions.updateEditSetting, + updateMapSetting: typeof mapActions.updateMapSetting, updatePatternGeometry: typeof mapActions.updatePatternGeometry, updatePatternStops: typeof tripPatternActions.updatePatternStops } -type State = { showNormalizeStopTimesModal: boolean } +type State = { + patternStopCandidate: ?GtfsStop, + showNormalizeStopTimesModal: boolean + } export default class PatternStopsPanel extends Component { state = { - showNormalizeStopTimesModal: false + showNormalizeStopTimesModal: false, + patternStopCandidate: null } _toggleAddStopsMode = () => { const {editSettings, updateEditSetting} = this.props + // Clear stop candidate (if defined). + this.setState({patternStopCandidate: null}) updateEditSetting({ setting: 'addStops', value: !editSettings.present.addStops @@ -58,12 +67,16 @@ export default class PatternStopsPanel extends Component { _onCloseModal = () => this.setState({showNormalizeStopTimesModal: false}) - _addStopFromSelect = (input: any) => { + _selectStop = (input: any) => { if (!input) { + // Clear stop candidate if input is cleared. + this.setState({patternStopCandidate: null}) return } const stop: GtfsStop = input.entity - return this.props.addStopToPattern(this.props.activePattern, stop) + // Zoom to stop candidate + this.props.updateMapSetting({bounds: getEntityBounds(stop), target: +stop.id}) + this.setState({patternStopCandidate: stop}) } render () { @@ -88,6 +101,7 @@ export default class PatternStopsPanel extends Component { updatePatternStops } = this.props const {addStops} = editSettings.present + const {patternStopCandidate} = this.state const patternHasStops = activePattern.patternStops && activePattern.patternStops.length > 0 return ( @@ -174,8 +188,24 @@ export default class PatternStopsPanel extends Component { + onChange={this._selectStop} /> + {patternStopCandidate + ?
+

{getEntityName(patternStopCandidate)}

+ +
+ : null + }
) - const derivedVersions = versions.filter(v => v.originNamespace === version.namespace) + const derivedVersions = versionSummaries.filter(v => v.originNamespace === version.namespace) return ( @@ -145,7 +144,7 @@ export default class GtfsPlusVersionSummary extends Component { Versions created from this version of GTFS+:
    {derivedVersions.map(v => { - const url = `/feed/${v.feedSource.id}/version/${v.version}` + const url = `/feed/${feedSource.id}/version/${v.version}` return (
  • diff --git a/lib/gtfsplus/containers/ActiveGtfsPlusEditor.js b/lib/gtfsplus/containers/ActiveGtfsPlusEditor.js index 245ffdbf6..1551b19d5 100644 --- a/lib/gtfsplus/containers/ActiveGtfsPlusEditor.js +++ b/lib/gtfsplus/containers/ActiveGtfsPlusEditor.js @@ -17,7 +17,6 @@ import { setVisibilityFilter } from '../actions/gtfsplus' import {getValidationIssuesForTable, getVisibleRows, getFilteredPageCount} from '../selectors' - import type {AppState, RouterProps} from '../../types/reducers' export type Props = RouterProps @@ -43,16 +42,16 @@ const mapStateToProps = (state: AppState, ownProps: Props) => { const feedSource = project && project.feedSources ? project.feedSources.find(fs => fs.id === feedSourceId) : null - const feedVersion = feedSource && feedSource.feedVersions - ? feedSource.feedVersions.find(v => v.id === feedVersionId) + const feedVersionSummary = feedSource && feedSource.feedVersionSummaries + ? feedSource.feedVersionSummaries.find(v => v.id === feedVersionId) : null return { activeTableId, currentPage, feedSource, feedSourceId, - feedVersion, feedVersionId, + feedVersionSummary, gtfsEntityLookup, pageCount: getFilteredPageCount(state), project, diff --git a/lib/gtfsplus/containers/ActiveGtfsPlusVersionSummary.js b/lib/gtfsplus/containers/ActiveGtfsPlusVersionSummary.js index 7d9c3c715..e58edb750 100644 --- a/lib/gtfsplus/containers/ActiveGtfsPlusVersionSummary.js +++ b/lib/gtfsplus/containers/ActiveGtfsPlusVersionSummary.js @@ -3,16 +3,14 @@ import {connect} from 'react-redux' import GtfsPlusVersionSummary from '../components/GtfsPlusVersionSummary' - import {deleteGtfsPlusFeed, downloadGtfsPlusFeed, publishGtfsPlusFeed} from '../actions/gtfsplus' import {getValidationIssuesForTable} from '../selectors' - -import type {FeedVersion} from '../../types' +import type {FeedVersion, FeedVersionSummary} from '../../types' import type {AppState} from '../../types/reducers' export type Props = { version: FeedVersion, - versions: Array + versionSummaries?: Array } const mapStateToProps = (state: AppState, ownProps: Props) => { diff --git a/lib/gtfsplus/reducers/gtfsplus.js b/lib/gtfsplus/reducers/gtfsplus.js index 99e7243a3..11baf6d36 100644 --- a/lib/gtfsplus/reducers/gtfsplus.js +++ b/lib/gtfsplus/reducers/gtfsplus.js @@ -3,7 +3,6 @@ import update from 'react-addons-update' import {constructNewGtfsPlusRow} from '../util' - import type {Action} from '../../types/actions' import type {GtfsPlusReducerState} from '../../types/reducers' @@ -58,7 +57,8 @@ const gtfsplus = ( continue } const fields = lines[0].split(',') - const COLUMN_REGEX = /,(?=(?:(?:[^"]*"){2})*[^"]*$)/ + // Column selector includes extra spaces on either side of comma. + const COLUMN_REGEX = / *, *(?=(?:(?:[^"]*"){2})*[^"]*$)/ const tableName = filename.split('.')[0] newTableData[tableName] = lines.slice(1) // Do not include blank lines (e.g., at the end of the file). diff --git a/lib/index.css b/lib/index.css index f7ae2991e..0fd25073a 100644 --- a/lib/index.css +++ b/lib/index.css @@ -17,5 +17,6 @@ @import url(node_modules/rc-slider/assets/index.css); @import url(node_modules/react-toggle/style.css); @import url(node_modules/react-toastify/dist/ReactToastify.min.css); +@import url(node_modules/react-dropdown-tree-select/dist/styles.css); @import url(lib/style.css); diff --git a/lib/manager/actions/__tests__/__snapshots__/projects.js.snap b/lib/manager/actions/__tests__/__snapshots__/projects.js.snap index 6cee153b7..8948f9151 100644 --- a/lib/manager/actions/__tests__/__snapshots__/projects.js.snap +++ b/lib/manager/actions/__tests__/__snapshots__/projects.js.snap @@ -5,6 +5,9 @@ Object { "active": null, "all": Array [ Object { + "autoDeploy": false, + "autoDeployTypes": Array [], + "autoDeployWithCriticalErrors": false, "autoFetchFeeds": true, "autoFetchHour": 0, "autoFetchMinute": 0, @@ -35,6 +38,7 @@ Object { "externalProperties": Object {}, "id": "mock-feed-with-version-id", "isPublic": false, + "labelIds": Array [], "lastFetched": 1543389038810, "lastUpdated": 1543389038810, "latestValidation": Object { @@ -70,6 +74,7 @@ Object { "transformRules": Array [], "url": "http://mdtrip.org/googletransit/AnnapolisTransit/google_transit.zip", "user": null, + "versionCount": 1, }, "id": "mock-feed-version-id", "nextVersionId": null, @@ -109,6 +114,10 @@ Object { "osmExtractUrl": null, "otpCommit": null, "otpVersion": null, + "peliasCsvFiles": Array [], + "peliasResetDb": null, + "peliasUpdate": null, + "pinnedfeedVersionIds": Array [], "projectBounds": Object { "east": 0, "north": 0, @@ -116,10 +125,9 @@ Object { "west": 0, }, "projectId": "mock-project-with-deployments-id", - "r5": false, - "r5Version": null, "routerId": null, "skipOsmExtract": false, + "tripPlannerVersion": "OTP_1", "user": null, }, ], @@ -131,6 +139,7 @@ Object { "externalProperties": Object {}, "id": "mock-feed-with-version-id", "isPublic": false, + "labelIds": Array [], "lastFetched": 1543389038810, "lastUpdated": 1543389038810, "latestValidation": Object { @@ -166,13 +175,16 @@ Object { "transformRules": Array [], "url": "http://mdtrip.org/googletransit/AnnapolisTransit/google_transit.zip", "user": null, + "versionCount": 1, }, ], "id": "mock-project-with-deployments-id", + "labels": Array [], "lastUpdated": 1553236399556, "name": "mock-project-with-deployments", "organizationId": null, "otpServers": Array [], + "peliasWebhookUrl": null, "pinnedDeploymentId": null, "routerConfig": Object { "carDropoffTime": null, @@ -190,6 +202,8 @@ Object { "feedSourceTableComparisonColumn": "DEPLOYED", "feedSourceTableFilterCountStrategy": "LATEST", "filter": null, + "labels": Array [], + "labelsFilterMode": "any", "searchText": null, }, "isFetching": false, @@ -205,6 +219,8 @@ Object { "feedSourceTableComparisonColumn": null, "feedSourceTableFilterCountStrategy": "LATEST", "filter": null, + "labels": Array [], + "labelsFilterMode": "any", "searchText": null, }, "isFetching": false, diff --git a/lib/manager/actions/deployments.js b/lib/manager/actions/deployments.js index 2b052ea81..a4f1a5122 100644 --- a/lib/manager/actions/deployments.js +++ b/lib/manager/actions/deployments.js @@ -4,14 +4,14 @@ import qs from 'qs' import { browserHistory } from 'react-router' import { createAction, type ActionType } from 'redux-actions' -import { createVoidPayloadAction, secureFetch } from '../../common/actions' -import { receiveProject } from './projects' -import { startJobMonitor } from './status' +import { createVoidPayloadAction, postFormData, secureFetch } from '../../common/actions' import fileDownload from '../../common/util/file-download' - -import type {Deployment, Feed, SummarizedFeedVersion} from '../../types' +import type {Deployment, Feed, ShapefileExportType, SummarizedFeedVersion} from '../../types' import type {dispatchFn, getStateFn} from '../../types/reducers' +import { handleJobResponse, startJobMonitor } from './status' +import { receiveProject } from './projects' + const DEPLOYMENT_URL = `/api/manager/secure/deployments` // Deployment Actions @@ -42,7 +42,7 @@ export type DeploymentActions = ActionType | ActionType | ActionType -type FeedVersionId = { id: string } +type FeedVersionId = { id: string } | { feedSourceId: string, version: number } export function addFeedVersion ( deployment: Deployment, @@ -88,7 +88,7 @@ export function deployToTarget (deployment: Deployment, target: string) { // FIXME: Use standard handleJobResponse for deployment job if (response.status >= 400 || response.status === 202) { // If there is an issue with the deployment, return the JSON response - // (handled be below alert) + // (handled below alert) return response.json() } else { // If the deployment request succeeded, start monitoring the job. @@ -103,6 +103,37 @@ export function deployToTarget (deployment: Deployment, target: string) { } } +export function updatePelias (deployment: Deployment, resetDb?: boolean) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + if (resetDb) { + await dispatch(updateDeployment(deployment, {peliasResetDb: true})) + } + const {id} = deployment + dispatch(secureFetch(`${DEPLOYMENT_URL}/${id}/updatepelias`, 'post')) + .then(response => { + // FIXME: Use standard handleJobResponse for deployment job + if (response.status >= 400 || response.status === 202) { + // If there is an issue starting the update, don't launch the job monitor + // (instead handle the error in the next promise block) + return + } else { + // If the deployment request succeeded, start monitoring the job. + dispatch(startJobMonitor()) + dispatch(fetchProjectDeployments(deployment.projectId)) + } + + if (resetDb) { + // If peliasResetDb was set, un-set it after getting a response + dispatch(updateDeployment(deployment, {peliasResetDb: false})) + } + }) + .then(json => { + // Show JSON message if there was an issue. + json && window.alert(json.message || 'Could not update Local Places Index') + }) + } +} + export function fetchDeployment (id: ?string) { return function (dispatch: dispatchFn, getState: getStateFn) { dispatch(requestingDeployment()) @@ -132,11 +163,38 @@ export function downloadBuildArtifact (deployment: Deployment, filename: ?string } } +/** + * Set a suggested file name for the given deployment. + * + * Note: Although a suggested file name is returned in the 'content-disposition' header of the 'download' endpoint, + * it is still needed to set it here because we trigger the "Save As" dialog separately from the actual fetch. + * Also, it is probably simpler to recompute the file name here instead of extracting it from the response. + * + * @param deployment The deployment object from which to derive the zip file name. + * @returns The suggested zip file name ending in ".zip" for the browser "Save As" dialog. + */ +function getDeploymentZipFileName (deployment: Deployment): string { + // Remove all spaces and special chars from deployment name. + const zipBase = deployment.name.replace(/[^a-zA-Z0-9]/g, '') + return `${zipBase}.zip` +} + export function downloadDeployment (deployment: Deployment) { return function (dispatch: dispatchFn, getState: getStateFn) { + const zipName = getDeploymentZipFileName(deployment) return dispatch(secureFetch(`${DEPLOYMENT_URL}/${deployment.id}/download`)) .then(response => response.blob()) - .then(blob => fileDownload(blob, 'test.zip', 'application/zip')) + .then(blob => fileDownload(blob, zipName, 'application/zip')) + } +} + +export function downloadDeploymentShapes (deployment: Deployment, type: ShapefileExportType) { + return function (dispatch: dispatchFn, getState: getStateFn) { + const url = `${DEPLOYMENT_URL}/${deployment.id}/shapes?type=${type}` + return dispatch(secureFetch(url, 'post')) + .then(res => { + dispatch(handleJobResponse(res, 'Error exporting GIS')) + }) } } @@ -209,6 +267,27 @@ export function createDeploymentFromFeedSource (feedSource: Feed) { } } +export function togglePinFeedVersion ( + deployment: Deployment, + version: SummarizedFeedVersion +) { + return function (dispatch: dispatchFn, getState: getStateFn) { + let pinnedfeedVersionIds + if (deployment.pinnedfeedVersionIds.includes(version.id)) { + // The feed version already exists in list of pinned feed versions. + // Therefore, the toggle outcome is to unpin the feed version. + pinnedfeedVersionIds = deployment.pinnedfeedVersionIds.filter( + id => id !== version.id + ) + } else { + // The feed version doesn't yet exist in list of pinned feed versions. + // Therefore, the toggle outcome is to pin the feed version. + pinnedfeedVersionIds = [...deployment.pinnedfeedVersionIds, version.id] + } + dispatch(updateDeployment(deployment, {pinnedfeedVersionIds})) + } +} + export function updateDeployment ( deployment: Deployment, changes: any // using the any type because $Shape was giving @@ -237,3 +316,48 @@ export function updateVersionForFeedSource ( return dispatch(updateDeployment(deployment, {feedVersions})) } } + +/** + * Uploads a csv file to the server, which uploads it to s3. Associates the resulting URL with + * a deployment + * @param {*} deployment The deployment to add the csv file to + * @param {*} file The csv file to upload + * @param {*} fileToDeleteOnSuccesfulUpload A URL to delete from the deployment on a successful upload + * @returns false if something fails, otherwise the deployment update + */ +export async function uploadPeliasWebhookCsvFile (deployment: Deployment, file: File, fileToDeleteOnSuccesfulUpload?: string | null) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + const { user } = getState() + if (!user || !user.token) { + window.alert('You are not logged in. Please log in again.') + return false + } + const { id } = deployment + const url = `${DEPLOYMENT_URL}/${id}/upload` + + const headers = {} + if (fileToDeleteOnSuccesfulUpload) { + headers.urlToDelete = fileToDeleteOnSuccesfulUpload + } + + const s3UploadResponse = await dispatch(postFormData(url, file, headers)) + if (s3UploadResponse) { + const updatedDeployment = await s3UploadResponse.json() + return dispatch(receiveDeployment(updatedDeployment)) + } + } +} + +/** + * Increments all feed versions in the deployment to the latest version, except + * for those that have been pinned to a specific version. + */ +export function incrementAllVersionsToLatest (deployment: Deployment) { + return function (dispatch: dispatchFn, getState: getStateFn) { + const feedVersions = deployment.feedVersions.map(v => + deployment.pinnedfeedVersionIds.includes(v.id) + ? v + : ({id: v.feedSource.latestVersionId})) + return dispatch(updateDeployment(deployment, {feedVersions})) + } +} diff --git a/lib/manager/actions/feeds.js b/lib/manager/actions/feeds.js index db0b401de..0e3eac057 100644 --- a/lib/manager/actions/feeds.js +++ b/lib/manager/actions/feeds.js @@ -1,18 +1,18 @@ // @flow -import {browserHistory} from 'react-router' -import {createAction, type ActionType} from 'redux-actions' +import { browserHistory } from 'react-router' +import { createAction, type ActionType } from 'redux-actions' -import {createVoidPayloadAction, secureFetch} from '../../common/actions' -import {isModuleEnabled} from '../../common/util/config' -import {fetchFeedSourceDeployments} from './deployments' -import {fetchSnapshots} from '../../editor/actions/snapshots' -import {fetchProject, fetchProjectWithFeeds} from './projects' -import {handleJobResponse} from './status' -import {fetchFeedVersions} from './versions' +import { createVoidPayloadAction, secureFetch } from '../../common/actions' +import { isModuleEnabled } from '../../common/util/config' +import { fetchSnapshots } from '../../editor/actions/snapshots' +import type { Feed, NewFeed } from '../../types' +import type { dispatchFn, getStateFn } from '../../types/reducers' -import type {Feed, NewFeed} from '../../types' -import type {dispatchFn, getStateFn} from '../../types/reducers' +import { fetchFeedSourceDeployments } from './deployments' +import { fetchProject, fetchProjectWithFeeds } from './projects' +import { handleJobResponse } from './status' +import { fetchFeedVersions } from './versions' // Private actions const requestingFeedSource = createVoidPayloadAction('REQUESTING_FEEDSOURCE') @@ -81,6 +81,7 @@ export function createFeedSource (newFeed: NewFeed) { export function updateFeedSource (feedSource: Feed, properties: {[string]: any}) { return function (dispatch: dispatchFn, getState: getStateFn) { dispatch(savingFeedSource()) + return dispatch(secureFetch(`${FS_URL}/${feedSource.id}`, 'put', {...feedSource, ...properties})) // Re-fetch feed source with versions and snapshots. .then((res) => dispatch(fetchFeedSource(feedSource.id))) diff --git a/lib/manager/actions/labels.js b/lib/manager/actions/labels.js new file mode 100644 index 000000000..354fb1c5f --- /dev/null +++ b/lib/manager/actions/labels.js @@ -0,0 +1,59 @@ +// @flow + +import { secureFetch } from '../../common/actions' +import { isModuleEnabled } from '../../common/util/config' +import type { dispatchFn, getStateFn } from '../../types/reducers' +import type { Label } from '../../types' + +import {fetchProjectDeployments} from './deployments' +import { fetchProject } from './projects' +import { fetchProjectFeeds } from './feeds' + +// Public action used by component or other actions + +const LABEL_URL = '/api/manager/secure/label' + +/** + * Create new label from provided properties. + */ +export function createLabel (label: Label) { + return function (dispatch: dispatchFn, getState: getStateFn) { + return dispatch(secureFetch(LABEL_URL, 'post', label)) + .then(res => res.json()) + .then(createdLabel => dispatch(refetchProject(createdLabel.projectId))) + } +} + +function refetchProject (projectId: string) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + // Wait for project to be fetched before fetching feeds/deployments. + await dispatch(fetchProject(projectId)) + dispatch(fetchProjectFeeds(projectId)) + isModuleEnabled('deployment') && dispatch(fetchProjectDeployments(projectId)) + } +} + +/** + * Update existing label with provided properties. + */ +export function updateLabel (dirtyLabel: Label, properties: {[string]: any}) { + // remove keys which the server doesn't like, which may be in the object + // TODO: is there a cleaner/more dynamic way to do this? Properties can't be guaranteed to + // include all the keys we need, so can't use that + const { organizationId, user, ...label } = dirtyLabel + + return function (dispatch: dispatchFn, getState: getStateFn) { + return dispatch(secureFetch(`${LABEL_URL}/${label.id}`, 'put', {...label})) + .then(() => dispatch(refetchProject(label.projectId))) + } +} + +/** + * Permanently delete single label + */ +export function deleteLabel (label: Label) { + return function (dispatch: dispatchFn, getState: getStateFn) { + return dispatch(secureFetch(`${LABEL_URL}/${label.id}`, 'delete')) + .then(() => dispatch(refetchProject(label.projectId))) + } +} diff --git a/lib/manager/actions/projects.js b/lib/manager/actions/projects.js index a62885cb3..d98fae081 100644 --- a/lib/manager/actions/projects.js +++ b/lib/manager/actions/projects.js @@ -4,7 +4,7 @@ import {createAction, type ActionType} from 'redux-actions' import {browserHistory} from 'react-router' import {createVoidPayloadAction, secureFetch} from '../../common/actions' -import {getConfigProperty} from '../../common/util/config' +import {getConfigProperty, isModuleEnabled} from '../../common/util/config' import {deploymentsEnabledAndAccessAllowedForProject} from '../../common/util/permissions' import {fetchProjectDeployments} from './deployments' import {fetchProjectFeeds} from './feeds' @@ -139,15 +139,16 @@ export function deleteProject (project: Project) { export function updateProject ( projectId: string, changes: {[string]: any}, - fetchFeeds: ?boolean = false + fetchFeedsAndDeployments: ?boolean = false ) { return function (dispatch: dispatchFn, getState: getStateFn) { dispatch(savingProject()) const url = `/api/manager/secure/project/${projectId}` return dispatch(secureFetch(url, 'put', changes)) .then((res) => { - if (fetchFeeds) { - return dispatch(fetchProjectWithFeeds(projectId)) + if (fetchFeedsAndDeployments) { + dispatch(fetchProjectWithFeeds(projectId)) + isModuleEnabled('deployment') && dispatch(fetchProjectDeployments(projectId)) } else { return dispatch(fetchProject(projectId)) } diff --git a/lib/manager/actions/status.js b/lib/manager/actions/status.js index e53eec23e..6bf8e61a7 100644 --- a/lib/manager/actions/status.js +++ b/lib/manager/actions/status.js @@ -1,18 +1,20 @@ // @flow +import * as React from 'react' import moment from 'moment' +import { browserHistory } from 'react-router' import { createAction, type ActionType } from 'redux-actions' import { createVoidPayloadAction, secureFetch } from '../../common/actions' -import {API_PREFIX} from '../../common/constants' -import {isExtensionEnabled} from '../../common/util/config' +import { API_PREFIX } from '../../common/constants' +import { getComponentMessages, isExtensionEnabled } from '../../common/util/config' +import { downloadSnapshotViaCredentials } from '../../editor/actions/snapshots' +import type {DataToolsConfig, Feed, MergeFeedsResult, ServerJob} from '../../types' +import type {dispatchFn, getStateFn} from '../../types/reducers' + import { fetchDeployment } from './deployments' import { fetchFeedSource } from './feeds' import { fetchProjectWithFeeds } from './projects' -import { downloadSnapshotViaCredentials } from '../../editor/actions/snapshots' - -import type {DataToolsConfig, MergeFeedsResult, ServerJob} from '../../types' -import type {dispatchFn, getStateFn} from '../../types/reducers' type ErrorMessage = { action?: string, @@ -23,7 +25,7 @@ type ErrorMessage = { type ModalContent = { action?: any, - body: string, + body: string | React.Node, detail?: any, title: string } @@ -136,45 +138,85 @@ export async function fetchAppInfo () { } } +/** + * Constructs modal content from an service period merge (principally used for + * MTC). + */ function getMergeFeedModalContent (result: MergeFeedsResult): ModalContent { + const { + errorCount, + failed, + failureReasons, + mergeStrategy, + remappedIds, + remappedReferences, + skippedIds, + tripIdsToCheck + } = result + const messages = getComponentMessages('MergeFeedsResult') const details = [] - // Do nothing or show merged feed modal? Feed version is be created - details.push('Remapped ID count: ' + result.remappedReferences) - if (Object.keys(result.remappedIds).length > 0) { - const remappedIdStrings = [] - for (let key in result.remappedIds) { - // Modify key to remove feed name. - const split = key.split(':') - const tableAndId = split.splice(1, 1) - remappedIdStrings.push(`${tableAndId.join(':')} -> ${result.remappedIds[key]}`) + + if (failed) { + if (tripIdsToCheck && tripIdsToCheck.length > 0) { + const tripIdListItems = tripIdsToCheck.map(id =>
  • {id}
  • ) + details.push(
    {messages('tripIdsToCheck').replace('%tripIdCount%', tripIdsToCheck.length.toString())}
    ) + details.push(
      {tripIdListItems}
    ) } - details.push('Remapped IDs: ' + remappedIdStrings.join(', ')) - } - if (result.skippedIds.length > 0) { - const skippedRecordsForTables = {} - result.skippedIds.forEach(id => { - const table = id.split(':')[0] - // Increment count of skipped records for each value found per table. - skippedRecordsForTables[table] = (skippedRecordsForTables[table] || 0) + 1 - }) - const skippedRecordsStrings = [] - for (let key in skippedRecordsForTables) { - skippedRecordsStrings.push(`${key} - ${skippedRecordsForTables[key]}`) + } else { + details.push( +
    + {messages('strategyUsed').replace('%strategy%', messages(mergeStrategy))} +
    + ) + details.push( +
    + {messages('remappedIds').replace('%remappedIdCount%', remappedReferences.toString())} +
    + ) + if (Object.keys(remappedIds).length > 0) { + // Sort remapped keys so that table names + // (the first portion of the keys) appear alphabetically. + const remappedIdStrings = Object.keys(remappedIds).sort().map(key => { + // Modify key to remove feed name. + const split = key.split(':') + split.splice(1, 1) // Reminder: splice() modifies the original array. + return
  • {split.join(':')} -> {remappedIds[key]}
  • + }) + details.push(
      {remappedIdStrings}
    ) + } + if (skippedIds.length > 0) { + const skippedRecordsForTables = {} + skippedIds.forEach(id => { + const table = id.split(':')[0] + // Increment count of skipped records for each value found per table. + skippedRecordsForTables[table] = (skippedRecordsForTables[table] || 0) + 1 + }) + const skippedRecordsStrings = Object.keys(skippedRecordsForTables).map(key => { + const tableItem = messages('skippedTableRecords') + .replace('%table%', key) + .replace('%skippedCount%', skippedRecordsForTables[key]) + return
  • {tableItem}
  • + }) + details.push(
    {messages('skipped')}
    ) + details.push(
      {skippedRecordsStrings}
    ) } - details.push('Skipped records: ' + skippedRecordsStrings.join(', ')) - } - if (result.idConflicts.length > 0) { - // const conflicts = result.idConflicts - details.push('ID conflicts: ' + result.idConflicts.join(', ')) } + return { - title: result.failed - ? 'Warning: Errors encountered during feed merge!' - : 'Feed merge was successful!', - body: result.failed - ? `Merge failed with ${result.errorCount} errors. ${result.failureReasons.join(', ')}` - : `Merge was completed successfully. A new version will be processed/validated containing the resulting feed.`, - detail: details.join('\n') + title: failed + ? messages('title.failure') + : messages('title.success'), + body: failed + ? ( +
    + {messages('body.failure').replace('%errorCount%', errorCount.toString())} +
      + {failureReasons.map((r, i) =>
    • {r}
    • )} +
    +
    + ) + : messages('body.success'), + detail: details } } @@ -190,6 +232,15 @@ export function handleFinishedJob (job: ServerJob) { return } dispatch(fetchFeedSource(job.feedSourceId)) + .then((feedSource: Feed) => { + // If viewing a particular feed, navigate to a new feed version as soon as it becomes available. + // (If user is not looking at this feed, don't navigate away from their current page.) + const newVersionPath = `/feed/${feedSource.id}` + if (browserHistory.getCurrentLocation().pathname.startsWith(`${newVersionPath}/version/`)) { + browserHistory.push(newVersionPath) + } + }) + if (isExtensionEnabled('mtc')) { const firstDate = job.validationResult && job.validationResult.firstCalendarDate const now = moment().startOf('day') @@ -231,6 +282,10 @@ export function handleFinishedJob (job: ServerJob) { // download via S3 (it never uploads the file to S3). window.location.assign(`${API_PREFIX}downloadshapes/${job.jobId}`) break + case 'EXPORT_DEPLOYMENT_GIS': + // Download shapefiles for deployment. See note above about temporary files. + window.location.assign(`${API_PREFIX}downloadshapes/${job.jobId}`) + break case 'EXPORT_SNAPSHOT_TO_GTFS': if (job.parentJobId) { console.log('Not downloading snapshot GTFS. Export job part of feed version creation.') @@ -275,8 +330,9 @@ export function handleJobResponse (response: Response, message: string) { : response.json() return dispatch(setErrorMessage(props)) } else { - // Resulting JSON contains message and job ID wich which to monitor job. + // Resulting JSON contains message and job ID with which to monitor job. const json: {jobId: number, message: string} = (response.json(): any) + dispatch(startJobMonitor()) // Return json with job ID in case it is needed upstream return json diff --git a/lib/manager/actions/versions.js b/lib/manager/actions/versions.js index d5dbf8359..a89fdb7df 100644 --- a/lib/manager/actions/versions.js +++ b/lib/manager/actions/versions.js @@ -12,12 +12,12 @@ import {ENTITY} from '../../editor/constants' import {getKeyForId} from '../../editor/util/gtfs' import {getEntityGraphQLRoot, getEntityIdField, getGraphQLFieldsForEntity} from '../../gtfs/util' import {validateGtfsPlusFeed} from '../../gtfsplus/actions/gtfsplus' +import type {Feed, FeedVersion, FeedVersionSummary, ShapefileExportType} from '../../types' +import type {dispatchFn, getStateFn, ValidationIssueCount} from '../../types/reducers' + import {handleJobResponse, setErrorMessage, startJobMonitor} from './status' import {fetchFeedSource} from './feeds' -import type {Feed, FeedVersion, ShapefileExportType} from '../../types' -import type {dispatchFn, getStateFn, ValidationIssueCount} from '../../types/reducers' - const deletingFeedVersion = createVoidPayloadAction('DELETING_FEEDVERSION') const publishedFeedVersion = createAction( 'PUBLISHED_FEEDVERSION', @@ -49,7 +49,7 @@ const receiveFeedVersions = createAction( versions: Array }) => payload ) -const receiveGTFSEntities = createAction( +export const receiveGTFSEntities = createAction( 'RECEIVE_GTFS_ENTITIES', (payload: { component: string, @@ -100,6 +100,7 @@ export function setActiveVersion (version: FeedVersion) { } } } + const uploadingFeed = createVoidPayloadAction('UPLOADING_FEED') export type VersionActions = ActionType | @@ -123,7 +124,7 @@ export function fetchFeedVersions (feedSource: Feed, unsecured: ?boolean = false return function (dispatch: dispatchFn, getState: getStateFn) { dispatch(requestingFeedVersions()) const apiRoot = unsecured ? 'public' : 'secure' - const url = `/api/manager/${apiRoot}/feedversion?feedSourceId=${feedSource.id}` + const url = `/api/manager/${apiRoot}/feedversionsummaries?feedSourceId=${feedSource.id}` return dispatch(secureFetch(url)) .then(response => response.json()) .then(versions => { @@ -162,7 +163,7 @@ export function publishFeedVersion (feedVersion: FeedVersion) { } /** - * Merges two feed versions according to the strategy defined within the + * Merges two feed versions according to the strategy defined by the mergeType parameter. */ export function mergeVersions (targetVersionId: string, versionId: string, mergeType: 'SERVICE_PERIOD' | 'REGIONAL') { return function (dispatch: dispatchFn, getState: getStateFn) { @@ -227,14 +228,14 @@ export function uploadFeed (feedSource: Feed, file: File) { * Permanently delete the feed version object and the loaded GTFS data in the * SQL database. */ -export function deleteFeedVersion (feedVersion: FeedVersion) { +export function deleteFeedVersion (feedVersion: FeedVersionSummary) { return function (dispatch: dispatchFn, getState: getStateFn) { dispatch(deletingFeedVersion()) const url = `${SECURE_API_PREFIX}feedversion/${feedVersion.id}` return dispatch(secureFetch(url, 'delete')) .then((res) => { // Re-fetch feed source with versions + snapshots - return dispatch(fetchFeedSource(feedVersion.feedSource.id)) + return dispatch(fetchFeedSource(feedVersion.feedSourceId)) }) } } @@ -507,13 +508,13 @@ export function fetchFeedVersionIsochrones ( * Download a GTFS file for a a given feed version. */ export function downloadFeedViaToken ( - feedVersion: FeedVersion, + feedVersionId: string, isPublic: ?boolean, prefix: ?string = 'gtfs' ) { return function (dispatch: dispatchFn, getState: getStateFn) { const route = isPublic ? 'public' : 'secure' - const url = `/api/manager/${route}/feedversion/${feedVersion.id}/downloadtoken` + const url = `/api/manager/${route}/feedversion/${feedVersionId}/downloadtoken` return dispatch(secureFetch(url)) .then(response => response.json()) .then(json => { @@ -570,18 +571,43 @@ export function renameFeedVersion ( } } +/** + * Ensures that the requested feed version has been fetched, + * and fetches the version if necessary. + * This method can be called using await. + */ +export function ensureVersionIndexIsFetched ( + feed: Feed, + oneBasedIndex: number +) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + if (!oneBasedIndex || oneBasedIndex < 1) return null + + const requestedVersion = feed.feedVersions && feed.feedVersions[oneBasedIndex - 1] + if (!requestedVersion) { + if (feed.feedVersionSummaries) { + // If the requested version has not been fetched yet, async fetch it. + const versionId = feed.feedVersionSummaries[oneBasedIndex - 1].id + const { payload } = await dispatch(fetchFeedVersion(versionId)) + return payload + } + } + return requestedVersion + } +} + export function setVersionIndex ( feed: Feed, - index: number, + oneBasedIndex: number, push?: boolean = true, isPublic?: boolean ) { - return function (dispatch: dispatchFn, getState: getStateFn) { - if (feed.feedVersions) { - dispatch(setActiveVersion(feed.feedVersions[index - 1])) - + return async function (dispatch: dispatchFn, getState: getStateFn) { + if (feed.feedVersionSummaries) { + const newActiveVersion = await dispatch(ensureVersionIndexIsFetched(feed, oneBasedIndex)) + dispatch(setActiveVersion(newActiveVersion)) if (push) { - browserHistory.push(`${isPublic ? '/public' : ''}/feed/${feed.id}/version/${index}`) + browserHistory.push(`${isPublic ? '/public' : ''}/feed/${feed.id}/version/${oneBasedIndex}`) } } else { console.warn('No feed versions for feed were found.', feed) @@ -589,6 +615,27 @@ export function setVersionIndex ( } } +/** + * Sets the compared feed version. + */ +export const settingComparedVersion = createAction( + 'SET_COMPARED_FEEDVERSION', + (payload: FeedVersion) => payload +) + +/** + * Sets the compared feed version based on the provided index. + */ +export function setComparedVersion ( + feed: Feed, + oneBasedIndex: number +) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + const newComparedVersion = await dispatch(ensureVersionIndexIsFetched(feed, oneBasedIndex)) + dispatch(settingComparedVersion(newComparedVersion)) + } +} + /** * Starts the export shapes server job for a particular feed version. * diff --git a/lib/manager/actions/visibilityFilter.js b/lib/manager/actions/visibilityFilter.js index e00202fed..8fba39c7e 100644 --- a/lib/manager/actions/visibilityFilter.js +++ b/lib/manager/actions/visibilityFilter.js @@ -1,11 +1,19 @@ // @flow -import {createAction, type ActionType} from 'redux-actions' +import { createAction, type ActionType } from 'redux-actions' export const setVisibilitySearchText = createAction( 'SET_PROJECT_VISIBILITY_SEARCH_TEXT', (payload: null | string) => payload ) +export const setVisibilityLabel = createAction( + 'SET_PROJECT_VISIBILITY_LABEL', + (payload: Array) => payload +) +export const setVisibilityLabelMode = createAction( + 'SET_PROJECT_VISIBILITY_LABEL_MODE', + (payload: string) => payload +) export const setVisibilityFilter = createAction( 'SET_PROJECT_VISIBILITY_FILTER', (payload: any) => payload diff --git a/lib/manager/components/AutoPublishSettings.js b/lib/manager/components/AutoPublishSettings.js new file mode 100644 index 000000000..d130935ca --- /dev/null +++ b/lib/manager/components/AutoPublishSettings.js @@ -0,0 +1,70 @@ +// @flow + +import React, { Component } from 'react' +import { + Checkbox, + Col, + FormGroup, + ListGroup, + ListGroupItem, + Panel +} from 'react-bootstrap' + +import * as feedsActions from '../actions/feeds' +import type { Feed } from '../../types' + +type Props = { + disabled: ?boolean, + feedSource: Feed, + updateFeedSource: typeof feedsActions.updateFeedSource +} + +/** + * This component displays auto-publish settings for a feed. + * Auto-publish settings are kept in a separate section per MTC request. + */ +export default class AutoPublishSettings extends Component { + _onToggleAutoPublish = () => { + const {feedSource, updateFeedSource} = this.props + updateFeedSource(feedSource, {autoPublish: !feedSource.autoPublish}) + } + + render () { + const { + disabled, + feedSource + } = this.props + // Do not allow users without manage-feed permission to modify auto-publish settings. + if (disabled) { + return ( +

    + User is not authorized to modify auto-publish settings. +

    + ) + } + return ( + + {/* Settings */} + Auto-publish Settings}> + + + + + Auto-publish this feed after auto-fetch + + + Set this feed source to be automatically published + when a new version is fetched automatically. + + + + + + + ) + } +} diff --git a/lib/manager/components/CollapsiblePanel.js b/lib/manager/components/CollapsiblePanel.js index 58960b782..0d33f15ec 100644 --- a/lib/manager/components/CollapsiblePanel.js +++ b/lib/manager/components/CollapsiblePanel.js @@ -20,6 +20,7 @@ type Props = { fields: Array, index: number, onChange: (SyntheticInputEvent, number) => void, + onEnter?: number => void, onRemove?: number => void, onSave?: number => any, saveDisabled: boolean, @@ -36,6 +37,8 @@ export default class CollapsiblePanel extends Component { _onChange = (evt: SyntheticInputEvent) => this.props.onChange(evt, this.props.index) + _onEnter = () => this.props.onEnter && this.props.onEnter(this.props.index) + _onRemove = () => this.props.onRemove && this.props.onRemove(this.props.index) _onSave = () => this.props.onSave && this.props.onSave(this.props.index) @@ -80,6 +83,7 @@ export default class CollapsiblePanel extends Component { data-test-id={testId} defaultExpanded={defaultExpanded} header={
    {title}
    } + onEnter={this._onEnter} >
    {this.renderButtonToolbar()} diff --git a/lib/manager/components/CreateFeedSource.js b/lib/manager/components/CreateFeedSource.js index a0b5d0fc9..9e3a3cedf 100644 --- a/lib/manager/components/CreateFeedSource.js +++ b/lib/manager/components/CreateFeedSource.js @@ -20,9 +20,12 @@ import validator from 'validator' import {createFeedSource} from '../actions/feeds' import Loading from '../../common/components/Loading' +import {FREQUENCY_INTERVALS} from '../../common/constants' +import {isExtensionEnabled} from '../../common/util/config' import {validationState} from '../util' +import type {FetchFrequency, NewFeed} from '../../types' -import type {NewFeed} from '../../types' +import FeedFetchFrequency from './FeedFetchFrequency' type Props = { createFeedSource: typeof createFeedSource, @@ -70,7 +73,10 @@ export default class CreateFeedSource extends Component { this.setState({ model: { autoFetchFeed: false, + autoPublish: false, deployable: false, + fetchFrequency: 'DAYS', + fetchInterval: 1, name: '', projectId: props.projectId, url: '' @@ -93,6 +99,28 @@ export default class CreateFeedSource extends Component { } ) + _onSelectFetchInterval = (fetchInterval: number) => { + const updatedState: State = update(this.state, { + model: {fetchInterval: {$set: fetchInterval}} + }) + this.setState(updatedState) + } + + _onSelectFetchFrequency = (fetchFrequency: FetchFrequency) => { + let {fetchInterval} = this.state.model + const intervals = FREQUENCY_INTERVALS[fetchFrequency] + if (intervals.indexOf(fetchInterval) === -1) { + fetchInterval = intervals[0] + } + const updatedState: State = update(this.state, { + model: { + fetchFrequency: {$set: fetchFrequency}, + fetchInterval: {$set: fetchInterval} + } + }) + this.setState(updatedState) + } + _onSave = () => { const {model, validation} = this.state // Prevent a save if the form has validation issues @@ -196,10 +224,39 @@ export default class CreateFeedSource extends Component { source URL must be specified and project auto fetch must be enabled.) + {model.autoFetchFeed + ? + : null + } + {isExtensionEnabled('mtc') && ( + Automatic publishing}> + + + + + Auto-publish this feed + + + Set this feed source to be automatically published + when a new version is automatically fetched. + + + + + + )} - - - - - {na} - - - {result.loadStatus} - - - {result.errorCount} - - - {result.routeCount} - - - {result.tripCount} - - - {result.stopTimesCount} - - - {formatTimestamp(result.startDate, false)} ({fromNow(result.startDate)}) - - - {formatTimestamp(result.endDate, false)} ({fromNow(result.endDate)}) - - - - - - ) - } -} diff --git a/lib/manager/components/DeploymentViewer.js b/lib/manager/components/DeploymentViewer.js deleted file mode 100644 index d77c09a5c..000000000 --- a/lib/manager/components/DeploymentViewer.js +++ /dev/null @@ -1,667 +0,0 @@ -// @flow - -import Icon from '@conveyal/woonerf/components/icon' -import React, {Component} from 'react' -import { LinkContainer } from 'react-router-bootstrap' -import { - Checkbox, - ControlLabel, - Radio, - FormGroup, - InputGroup, - HelpBlock, - ListGroup, - ListGroupItem, - Row, - Col, - Button, - Panel, - FormControl, - Glyphicon, - Badge, - ButtonGroup, - DropdownButton, - MenuItem -} from 'react-bootstrap' -import {Map, TileLayer, Rectangle} from 'react-leaflet' -import Select from 'react-select' -import fetch from 'isomorphic-fetch' -import validator from 'validator' - -import * as deploymentActions from '../actions/deployments' -import Loading from '../../common/components/Loading' -import EditableTextField from '../../common/components/EditableTextField' -import Title from '../../common/components/Title' -import WatchButton from '../../common/containers/WatchButton' -import {getComponentMessages, getConfigProperty} from '../../common/util/config' -import {formatTimestamp, fromNow} from '../../common/util/date-time' -import {isValidJSONC} from '../../common/util/json' -import {defaultTileLayerProps} from '../../common/util/maps' -import { versionsSorter } from '../../common/util/util' -import {getServerDeployedTo} from '../util/deployment' -import CurrentDeploymentPanel from './CurrentDeploymentPanel' -import DeploymentConfirmModal from './DeploymentConfirmModal' -import DeploymentVersionsTable from './DeploymentVersionsTable' - -import type {Props as ContainerProps} from '../containers/ActiveDeploymentViewer' -import type { - Deployment, - ReactSelectOption, - ServerJob, - SummarizedFeedVersion -} from '../../types' -import type {ManagerUserState} from '../../types/reducers' - -type Props = ContainerProps & { - addFeedVersion: typeof deploymentActions.addFeedVersion, - deleteFeedVersion: typeof deploymentActions.deleteFeedVersion, - deployJobs: Array, - deployToTarget: typeof deploymentActions.deployToTarget, - downloadBuildArtifact: typeof deploymentActions.downloadBuildArtifact, - downloadDeployment: typeof deploymentActions.downloadDeployment, - fetchDeployment: typeof deploymentActions.fetchDeployment, - terminateEC2InstanceForDeployment: typeof deploymentActions.terminateEC2InstanceForDeployment, - updateDeployment: typeof deploymentActions.updateDeployment, - updateVersionForFeedSource: typeof deploymentActions.updateVersionForFeedSource, - user: ManagerUserState -} - -type State = { - otp: Array, - r5: Array, - searchText: ?string, - target: ?string -} - -const BOUNDS_LIMIT = 10 // Limit for the decimal degrees span - -const SAMPLE_BUILD_CONFIG = `{ - "subwayAccessTime": 2.5 -}` - -const SAMPLE_ROUTER_CONFIG = `{ - "routingDefaults": { - "walkSpeed": 2.0, - "stairsReluctance": 4.0, - "carDropoffTime": 240 - } -}` - -export default class DeploymentViewer extends Component { - messages = getComponentMessages('DeploymentViewer') - state = { - searchText: null, - target: null, - r5: [], - otp: [] - } - - componentDidMount () { - this.resetMap() - // Fetch the available OTP and R5 build versions from S3. - this._loadOptions() - } - - /** - * When the component remounts after an initial mount, the map needs to be - * reset using Leaflet.invalidateSize so that the map tiles do not "grey out." - * - * Also, this re-fits the bounds to the deployment bounds. - */ - resetMap () { - setTimeout(() => { - if (this.refs.map) { - this.refs.map.leafletElement.invalidateSize() - this.refs.map.leafletElement.fitBounds(this._getBounds()) - } - }, 500) - } - - _getBounds = () => { - const {east, north, south, west} = this.props.deployment.projectBounds || - {east: 10, north: 10, west: -10, south: -10} - return [[north, east], [south, west]] - } - - /** - * Parse .jar options from an S3 text XML response. - * @param {string} text text response from s3 - * @param {string} key state key under which to store options - */ - _parseOptionsFromXml = (text: string, key: 'otp' | 'r5') => { - const parser = new window.DOMParser() - const doc = parser.parseFromString(text, 'application/xml') - - const all = Array.from(doc.querySelectorAll('Contents')) - .map(item => item.querySelector('Key').childNodes[0].nodeValue) // get just key - .filter(item => item !== 'index.html') // don't include the main page - .map(item => item.replace(/.jar$/, '')) // and remove .jar - this.setState({[key]: all}) - } - - _loadAndParseOptionsFromXml = (url: string, key: 'otp' | 'r5') => { - fetch(url) - .then(res => res.text()) - .then(text => this._parseOptionsFromXml(text, key)) - } - - /** - * Load .jar options from OTP and R5 S3 buckets. - */ - _loadOptions = () => { - const r5Url = getConfigProperty('modules.deployment.r5_download_url') || 'https://r5-builds.s3.amazonaws.com' - this._loadAndParseOptionsFromXml(r5Url, 'r5') - const otpUrl = getConfigProperty('modules.deployment.otp_download_url') || 'https://opentripplanner-builds.s3.amazonaws.com' - this._loadAndParseOptionsFromXml(otpUrl, 'otp') - } - - _onAddFeedSource = (feedSourceId: string) => { - const feed = this.props.feedSources.find(fs => fs.id === feedSourceId) - const id = feed && feed.latestVersionId - if (!id) { - console.warn('No latest version ID found for feed', feed) - return - } - this.props.addFeedVersion(this.props.deployment, {id}) - } - - _onChangeSearch = (evt: SyntheticInputEvent) => - this.setState({searchText: evt.target.value}) - - _onChangeName = (name: string) => this._updateDeployment({name}) - - _onChangeR5 = () => this._updateDeployment({r5: !this.props.deployment.r5}) - - _onChangeBuildGraphOnly = () => this._updateDeployment({buildGraphOnly: !this.props.deployment.buildGraphOnly}) - - _onChangeSkipOsmExtract = () => { - const {skipOsmExtract} = this.props.deployment - if (!skipOsmExtract) { - // If changing from including OSM to skipping OSM, verify that this is - // intentional. - if (!window.confirm('Are you sure you want to exclude an OSM extract from the graph build? This will prevent the use of the OSM street network in routing results.')) { - return - } - } - this._updateDeployment({skipOsmExtract: !skipOsmExtract}) - } - - _onClickDownload = () => this.props.downloadDeployment(this.props.deployment) - - _onCloseModal = () => this.setState({target: null}) - - _setOsmUrl = () => { - const currentUrl = this.props.deployment.osmExtractUrl || '' - const osmExtractUrl = window.prompt( - 'Please provide a public URL from which to download an OSM extract (.pbf).', - currentUrl - ) - if (osmExtractUrl) { - if (!validator.isURL(osmExtractUrl)) { - window.alert(`URL ${osmExtractUrl} is invalid!`) - return - } - this._updateDeployment({osmExtractUrl}) - } - } - - _clearOsmUrl = () => this._updateDeployment({osmExtractUrl: null}) - - _onSelectTarget = (target: string) => this.setState({target}) - - _onUpdateVersion = (option: ReactSelectOption) => { - const key = this.props.deployment.r5 ? 'r5Version' : 'otpVersion' - this._updateDeployment({[key]: option.value}) - } - - _updateDeployment = (props: {[string]: any}) => { - const {deployment, updateDeployment} = this.props - updateDeployment(deployment, props) - } - - renderHeader () { - const { - deployJobs, - deployment, - project, - user - } = this.props - const na = (N/A) - const isWatchingDeployment = user.subscriptions && - user.subscriptions.hasFeedSubscription( - project.id, - deployment.id, - 'deployment-updated' - ) - - return ( -
    -

    - - - - - {' '} - {this.messages('deploy')} - - : {this.messages('noServers')} - } - onSelect={this._onSelectTarget}> - {project.otpServers - ? project.otpServers.map((server, i) => ( - 0 || - !server.name || - ( - (!server.internalUrl || server.internalUrl.length === 0) && - !server.s3Bucket - ) || - (server.ec2Info && server.ec2Info.targetGroupArn && deployment.feedSourceId) - } - eventKey={server.id} - key={server.id} - > - {server.name || '[Unnamed]'} - - )) - : null - } - {project.otpServers && } - - - Manage servers - - - - - -

    - - - {' '} - Last deployed {deployment.lastDeployed ? fromNow(deployment.lastDeployed) : na} - -
    - ) - } - - renderMap () { - const { deployment } = this.props - const isMissingBounds = !deployment.projectBounds - const {east, north, south, west} = deployment.projectBounds || - {east: 10, north: 10, west: -10, south: -10} - const boundsTooLarge = east - west > BOUNDS_LIMIT || north - south > BOUNDS_LIMIT - const bounds = this._getBounds() - - return ( - - - {!isMissingBounds && - - } - - ) - } - - renderDeployentVersionsTableHeader (versions: Array) { - const { deployment, feedSources, project } = this.props - const { feedVersions } = deployment - if (!deployment || !project || !feedSources) return - // Only permit adding feed sources to non-feed source deployments. - const deployableFeeds = !deployment.feedSourceId && feedSources - ? feedSources.filter(fs => - // Include feed sources that are not currently added, have feed versions, - // are marked deployable, and have been validated. - feedVersions && feedVersions.findIndex(v => v.feedSource.id === fs.id) === -1 && - fs.deployable && - fs.latestValidation - ) - : [] - const earliestDate = formatTimestamp(`${Math.min(...versions.map(v => +v.validationResult.startDate))}`, false) - const latestDate = formatTimestamp(`${Math.max(...versions.map(v => +v.validationResult.endDate))}`, false) - - return ( - - -

    - {this.messages('versions')}{' '} - {versions.length} -
    - - {' '} - {earliestDate} {this.messages('to')} {latestDate} - -

    - - - - - - - {' '} - {this.messages('addFeedSource')} - - : {this.messages('allFeedsAdded')} - } - onSelect={this._onAddFeedSource}> - {deployableFeeds.map((fs, i) => ( - {fs.name} - ))} - - - - -
    - ) - } - - renderConfigurationsPanel () { - const { deployment, updateDeployment } = this.props - const options = deployment.r5 ? this.state.r5 : this.state.otp - - return ( - OTP Configuration}> - - - Use R5 - Build graph only - {deployment.r5 ? 'R5' : 'OTP'} version - + ) +} + +export default GtfsFieldSelector diff --git a/lib/manager/components/HomeProjectDropdown.js b/lib/manager/components/HomeProjectDropdown.js index d192e9316..699056091 100644 --- a/lib/manager/components/HomeProjectDropdown.js +++ b/lib/manager/components/HomeProjectDropdown.js @@ -2,11 +2,13 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' -import { Button, DropdownButton, MenuItem } from 'react-bootstrap' +import { Button } from 'react-bootstrap' +import {browserHistory} from 'react-router' import { LinkContainer } from 'react-router-bootstrap' -import {getAbbreviatedProjectName} from '../../common/util/util' +import Select from 'react-select' -import type {Project} from '../../types' +import {getAbbreviatedProjectName} from '../../common/util/util' +import type {Project, ReactSelectOption} from '../../types' import type {ManagerUserState} from '../../types/reducers' type Props = { @@ -16,6 +18,18 @@ type Props = { } export default class HomeProjectDropdown extends Component { + handleChange = (option: ReactSelectOption) => { + browserHistory.push(`/home/${option ? option.value : ''}`) + } + + _optionRenderer = (option: ReactSelectOption) => { + return ( + + {option.label} + + ) + } + render () { const { activeProject, @@ -26,77 +40,50 @@ export default class HomeProjectDropdown extends Component { const {profile} = user if (!profile) return null const abbreviatedProjectName = getAbbreviatedProjectName(activeProject) + const options = visibleProjects.map( + project => ({value: project.id, label: project.name || '[No name]'}) + ) return (
    - {activeProject - ? - - - : null - } - - {abbreviatedProjectName} - - : - {profile.email} - {' '} - {profile.nickname} - - } - > - {activeProject && ( - - - - {profile.email} - {' '} - {profile.nickname} - - + { + isAdmin && ( + + - )} - {activeProject && } - {visibleProjects.length > 0 - ? visibleProjects.map((project, index) => { - if (activeProject && project.id === activeProject.id) { - return null + ) + } +
    +
    + + + + + of the labels: + +
    + {project.labels.map((label) => ( + this._onLabelClick(label.id)} + /> + ))} +
    + + ) + } + _renderFilterToolbarLabel = () => { const {filter, possibleComparisons} = this.props @@ -330,7 +389,8 @@ export default class ProjectFeedListToolbar extends PureComponent { const activeFilter = filter.filter || 'all' const nonFilterColumnOffset = 25 - + const activeFilterLabelCount = (filter.labels && filter.labels.length) || 0 + const badgeStyle = { backgroundColor: '#babec0' } return ( @@ -347,6 +407,7 @@ export default class ProjectFeedListToolbar extends PureComponent { { {this._renderFilterToolbarLabel()} - - {Object.keys(versionStatusFilters).map(filterOption => ( + + {Object.keys(versionStatusFilters).map((filterOption) => ( {this.messages(`filter.${filterOption}`)}{' '} - + {filterCounts[filterOption]} ))} + + {' '} + + {activeFilterLabelCount} + + + } + > + {this._renderLabelFilter()} + - {!projectEditDisabled && + {!projectEditDisabled && ( { > {this.messages('feeds.new')} - } + )} {this._renderSyncMenuItem('transitland')} {this._renderSyncMenuItem('transitfeeds')} {this._renderSyncMenuItem('mtc')} @@ -404,16 +480,10 @@ export default class ProjectFeedListToolbar extends PureComponent { > {this.messages('feeds.update')} - + {this.messages('mergeFeeds')} - + {this.messages('downloadCsv')} diff --git a/lib/manager/components/ProjectSettings.js b/lib/manager/components/ProjectSettings.js index 7862fa453..fe80b60e6 100644 --- a/lib/manager/components/ProjectSettings.js +++ b/lib/manager/components/ProjectSettings.js @@ -1,22 +1,27 @@ // @flow -import React, {Component} from 'react' -import {Row, Col, Panel, ListGroup, ListGroupItem} from 'react-bootstrap' -import {LinkContainer} from 'react-router-bootstrap' +import React, { Component } from 'react' +import { Row, Col, Panel, ListGroup, ListGroupItem } from 'react-bootstrap' +import { LinkContainer } from 'react-router-bootstrap' -import {deleteProject, updateProject} from '../actions/projects' -import {getComponentMessages, isModuleEnabled} from '../../common/util/config' -import DeploymentSettings from './DeploymentSettings' -import ProjectSettingsForm from './ProjectSettingsForm' +import * as projectActions from '../actions/projects' +import { + getComponentMessages, + isModuleEnabled +} from '../../common/util/config' +import type { ManagerUserState } from '../../types/reducers' +import type { Project } from '../../types' -import type {Project} from '../../types' +import DeploymentSettings from './deployment/DeploymentSettings' +import ProjectSettingsForm from './ProjectSettingsForm' type Props = { activeSettingsPanel?: ?string, - deleteProject: typeof deleteProject, + deleteProject: typeof projectActions.deleteProject, project: Project, projectEditDisabled: boolean, - updateProject: typeof updateProject + updateProject: typeof projectActions.updateProject, + user: ManagerUserState } export default class ProjectSettings extends Component { @@ -34,7 +39,8 @@ export default class ProjectSettings extends Component { deleteProject, project, projectEditDisabled, - updateProject + updateProject, + user } = this.props const activePanel = !activeSettingsPanel ? { project={project} updateProject={updateProject} showDangerZone + user={user} /> : { validation: { bounds: true, defaultLocation: true, - name: true + name: true, + webhookUrl: true } } @@ -180,13 +184,16 @@ export default class ProjectSettingsForm extends Component { this.setState(update(this.state, {model: {$merge: {defaultTimeZone}}})) } - _onChangeName = ({target}: {target: HTMLInputElement}) => { + _onChangeTextInput = ({target}: {target: HTMLInputElement}) => { const {name, value} = target this.setState( - update(this.state, { - model: { $merge: {[name]: value} }, - validation: { [name]: { $set: value && value.length > 0 } } - }) + update( + this.state, + { + model: { $merge: { [ name ]: value } }, + validation: { [ name ]: { $set: value && value.length > 0 } } + } + ) ) } @@ -235,7 +242,7 @@ export default class ProjectSettingsForm extends Component { _getChanges = () => { const {model} = this.state const {project} = this.props - let changes: any = {} + const changes: any = {} Object.keys(model).map(k => { if (model[k] !== project[k]) { changes[k] = model[k] @@ -272,13 +279,12 @@ export default class ProjectSettingsForm extends Component { + validationState={validationState(validation.name)}> {this.messages('fields.name')} Required. @@ -299,19 +305,17 @@ export default class ProjectSettingsForm extends Component { {autoFetchChecked ? - : null - } + onChange={this._onChangeDateTime} + /> + : null} @@ -329,22 +333,21 @@ export default class ProjectSettingsForm extends Component { type='text' defaultValue={model.bounds ? `${model.bounds.west},${model.bounds.south},${model.bounds.east},${model.bounds.north}` - : '' - } + : ''} ref='boundingBox' - placeholder={this.messages('fields.location.boundingBoxPlaceHolder')} - onChange={this._onChangeBounds} /> - { - - - - } + placeholder={this.messages( + 'fields.location.boundingBoxPlaceHolder' + )} + onChange={this._onChangeBounds} + /> + { + {/* TODO: wait for react-leaflet-draw to update library + to re-enable bounds select. This button appears to be permanently + disabled. The git blame history may provide more detail. */} + + } @@ -355,7 +358,23 @@ export default class ProjectSettingsForm extends Component { + onChange={this._onChangeTimeZone} + /> + + + + Local Places Index}> + + + + Webhook URL + + + @@ -379,22 +398,16 @@ export default class ProjectSettingsForm extends Component { } - {/* Cancel button */} - - {/* Save button */} + {/* Save Button */} diff --git a/lib/manager/components/ProjectViewer.js b/lib/manager/components/ProjectViewer.js index a387f85e1..b99941c1e 100644 --- a/lib/manager/components/ProjectViewer.js +++ b/lib/manager/components/ProjectViewer.js @@ -21,18 +21,20 @@ import {shallowEqual} from 'react-pure-render' import * as deploymentActions from '../actions/deployments' import * as feedsActions from '../actions/feeds' import * as projectsActions from '../actions/projects' +import Loading from '../../common/components/Loading' import ManagerPage from '../../common/components/ManagerPage' import WatchButton from '../../common/containers/WatchButton' import {getComponentMessages, getConfigProperty, isModuleEnabled} from '../../common/util/config' import DeploymentsPanel from '../containers/DeploymentsPanel' import FeedSourceTable from '../containers/FeedSourceTable' -import ProjectSettings from './ProjectSettings' -import CreateFeedSource from './CreateFeedSource' - import type {Props as ContainerProps} from '../containers/ActiveProjectViewer' import type {Feed, Project} from '../../types' import type {ManagerUserState} from '../../types/reducers' +import CreateFeedSource from './CreateFeedSource' +import ProjectSettings from './ProjectSettings' +import LabelPanel from './LabelPanel' + type Props = ContainerProps & { activeComponent: ?string, activeSubComponent: ?string, @@ -74,6 +76,60 @@ export default class ProjectViewer extends Component { onProjectViewerMount(projectId) } + _renderDeploymentsTab = () => { + const {activeComponent, activeSubComponent, project} = this.props + return isModuleEnabled('deployment') && ( + + {this.messages('deployments')} + + }> + + + ) + } + + _renderPublicFeeds = () => { + const s3Bucket = getConfigProperty('application.data.gtfs_s3_bucket') + const publicFeedsLink = s3Bucket && + `https://s3.amazonaws.com/${s3Bucket}/public/index.html` + return isModuleEnabled('enterprise') && !this._isProjectEditDisabled() && +
    + + {s3Bucket && +

    + Note: Public feeds page can be viewed{' '} + here. +

    + } +
    + } + + _isProjectEditDisabled = () => { + const {project, user} = this.props + return !user.permissions || + !user.permissions.isProjectAdmin(project.id, project.organizationId) + } + + _isWatchingProject = () => { + const {project, user} = this.props + return user.subscriptions && + user.subscriptions.hasProjectSubscription(project.id, 'project-updated') + } + _onClickDeploy = () => this.props.deployPublic(this.props.project) _toggleCreateView = () => { this.setState({ createMode: !this.state.createMode }) } @@ -87,15 +143,15 @@ export default class ProjectViewer extends Component { project, user } = this.props - if (isFetching && !project) { - // Show spinner if fetching (and there is no current feed source loaded). + if (activeComponent !== 'deployment' && isFetching && !project) { + // Show spinner if fetching (and the project hasn't loaded yet). return (

    - +

    @@ -108,7 +164,9 @@ export default class ProjectViewer extends Component { -

    No project found for {this.props.projectId}

    +

    + No project found for {this.props.projectId} +

    Return to list of projects

    @@ -116,17 +174,9 @@ export default class ProjectViewer extends Component { ) } - const s3Bucket = getConfigProperty('application.data.gtfs_s3_bucket') - const publicFeedsLink = s3Bucket - ? `https://s3.amazonaws.com/${s3Bucket}/public/index.html` - : null - const isWatchingProject = user.subscriptions && user.subscriptions.hasProjectSubscription(project.id, 'project-updated') - const projectEditDisabled = !user.permissions || !user.permissions.isProjectAdmin(project.id, project.organizationId) - + const projectEditDisabled = this._isProjectEditDisabled() return ( - + @@ -134,18 +184,28 @@ export default class ProjectViewer extends Component { {project.name} - {getConfigProperty('application.notifications_enabled') - ? - : null - } + {getConfigProperty('application.notifications_enabled') ? : null} -
      -
    • {project.autoFetchFeeds ? `${project.autoFetchHour}:${project.autoFetchMinute < 10 ? '0' + project.autoFetchMinute : project.autoFetchMinute}` : 'Auto fetch disabled'}
    • +
        +
      • + {' '} + {project.autoFetchFeeds + ? `${project.autoFetchHour}:${ + project.autoFetchMinute < 10 + ? '0' + project.autoFetchMinute + : project.autoFetchMinute + }` + : 'Auto fetch disabled'} +
      @@ -153,79 +213,43 @@ export default class ProjectViewer extends Component { id='project-viewer-tabs' activeKey={activeComponent || 'sources'} mountOnEnter - onSelect={this._selectTab}> + onSelect={this._selectTab} + > - {this.messages('feeds.title')} + + {this.messages('feeds.title')} + - }> + } + > - {this.state.createMode - ? ( - - ) - : ( - - ) - } + {this.state.createMode ? () : ( + )} - {isModuleEnabled('enterprise') && !projectEditDisabled && -
      - - {s3Bucket && -

      - Note: Public feeds page can be viewed{' '} - here. -

      - } -
      - } + {this._renderPublicFeeds()} - What is a feed source?}> - A feed source defines the location or upstream source of a{' '} - GTFS feed. GTFS can be populated via automatic fetch,{' '} - directly editing or uploading a zip file. - + feedSources={project.feedSources || []} + /> + +
      - {isModuleEnabled('deployment') - ? - {this.messages('deployments')} - - }> - - - : null - } + {this._renderDeploymentsTab()} { {this.messages('settings')} - }> - {// Prevent rendering component if not active to ensure that - // keyboard listener is not active while form is not visible. - activeComponent === 'settings' && - } + > + {// Prevent rendering component if not active to ensure that + // keyboard listener is not active while form is not visible. + activeComponent === 'settings' && ( + + )} @@ -276,3 +302,18 @@ class ProjectSummaryPanel extends Component<{feedSources: Array, project: ) } } + +const ExplanatoryPanel = ({ project }) => { + // If user has more than 3 labels, hide the feed source instruction + if (project.labels.length <= 3) { + return ( + What is a feed source?}> + A feed source defines the location or upstream source of a GTFS feed. + GTFS can be populated via automatic fetch, directly editing or uploading + a zip file. + + ) + } + + return
      +} diff --git a/lib/manager/components/TransformationsViewer.js b/lib/manager/components/TransformationsViewer.js new file mode 100644 index 000000000..87a5bb2c2 --- /dev/null +++ b/lib/manager/components/TransformationsViewer.js @@ -0,0 +1,58 @@ +// @flow + +import React, { Component } from 'react' +import Icon from '@conveyal/woonerf/components/icon' +import { Col, ListGroup, Panel, Row, ListGroupItem, Label as BsLabel } from 'react-bootstrap' + +import type { FeedVersion, TableTransformResult } from '../../types' + +type Props = { + version: FeedVersion, +} + +export default class TransformationsViewer extends Component { + _getBadge (transformResult: TableTransformResult) { + switch (transformResult.transformType) { + case 'TABLE_MODIFIED': + return Table Modified + case 'TABLE_ADDED': + return Table Added + case 'TABLE_REPLACED': + return Table Replaced + case 'TABLE_DELETED': + return Table Deleted + } + } + + render () { + const { + version + } = this.props + + if (version.feedTransformResult && version.feedTransformResult.tableTransformResults) { + const {tableTransformResults} = version.feedTransformResult + const transformContent = tableTransformResults.map(res => { + const badge = this._getBadge(res) + return ( + +

      {res.tableName} {badge}

      + + Rows added: {res.addedCount} + Rows deleted: {res.deletedCount} + Rows updated: {res.updatedCount} + +
      + ) + }) + return ( + Transformations}> + + {transformContent} + + + ) + } else { + return

      No transformations applied.

      + } + } +} diff --git a/lib/manager/components/CurrentDeploymentPanel.js b/lib/manager/components/deployment/CurrentDeploymentPanel.js similarity index 96% rename from lib/manager/components/CurrentDeploymentPanel.js rename to lib/manager/components/deployment/CurrentDeploymentPanel.js index 474144784..3f4f9bd47 100644 --- a/lib/manager/components/CurrentDeploymentPanel.js +++ b/lib/manager/components/deployment/CurrentDeploymentPanel.js @@ -9,11 +9,11 @@ import { Label as BsLabel } from 'react-bootstrap' -import * as deploymentActions from '../actions/deployments' -import { formatTimestamp } from '../../common/util/date-time' -import { getActiveInstanceCount, getServerForId } from '../util/deployment' +import * as deploymentActions from '../../actions/deployments' +import { formatTimestamp } from '../../../common/util/date-time' +import { getActiveInstanceCount, getServerForId } from '../../util/deployment' import DeploymentPreviewButton from './DeploymentPreviewButton' -import EC2InstanceCard from '../../common/components/EC2InstanceCard' +import EC2InstanceCard from '../../../common/components/EC2InstanceCard' import type { Deployment, @@ -22,7 +22,7 @@ import type { OtpServer, Project, ServerJob -} from '../../types' +} from '../../../types' type Props = { deployJobs: Array, diff --git a/lib/manager/components/deployment/CustomConfig.js b/lib/manager/components/deployment/CustomConfig.js new file mode 100644 index 000000000..b7ad588e9 --- /dev/null +++ b/lib/manager/components/deployment/CustomConfig.js @@ -0,0 +1,124 @@ +// @flow + +import Icon from '@conveyal/woonerf/components/icon' +import React, {Component} from 'react' +import { + Button, + FormControl, + FormGroup, + HelpBlock, + Radio +} from 'react-bootstrap' +import { LinkContainer } from 'react-router-bootstrap' + +import * as deploymentActions from '../../actions/deployments' +import {isValidJSONC} from '../../../common/util/json' + +import type { + Deployment +} from '../../../types' + +const SAMPLE_BUILD_CONFIG = `{ + "subwayAccessTime": 2.5 +}` + +const SAMPLE_ROUTER_CONFIG = `{ + "routingDefaults": { + "walkSpeed": 2.0, + "stairsReluctance": 4.0, + "carDropoffTime": 240 + } +}` + +export default class CustomConfig extends Component<{ + deployment: Deployment, + label: string, + name: string, + updateDeployment: typeof deploymentActions.updateDeployment +}, {[string]: any}> { + state = {} + + _toggleCustomConfig = (evt: SyntheticInputEvent) => { + const {deployment, updateDeployment} = this.props + const {name} = evt.target + const value = deployment[name] + ? null + : name === 'customBuildConfig' + ? SAMPLE_BUILD_CONFIG + : SAMPLE_ROUTER_CONFIG + updateDeployment(deployment, {[name]: value}) + } + + _onChangeConfig = (evt: SyntheticInputEvent) => + this.setState({[this.props.name]: evt.target.value}) + + _onSaveConfig = () => { + const {deployment, name, updateDeployment} = this.props + const value = this.state[name] + if (!isValidJSONC(value)) return window.alert('Must provide valid JSON string.') + else { + updateDeployment(deployment, {[name]: value}) + this.setState({[name]: undefined}) + } + } + + render () { + const {deployment, name, label} = this.props + const useCustom = deployment[name] !== null + const value = this.state[name] || deployment[name] + const validJSON = isValidJSONC(value) + return ( +
      +
      {label} configuration
      + + + Project default + + + Custom + + +

      + {useCustom + ? `Use custom JSON defined below for ${label} configuration.` + : `Use the ${label} configuration defined in the project deployment settings.` + } + {' '} + {useCustom + ? + : + + + } + +

      + {useCustom && + + + {!validJSON && Must provide valid JSON string.} + + } +
      + ) + } +} diff --git a/lib/manager/components/deployment/CustomFileEditor.js b/lib/manager/components/deployment/CustomFileEditor.js new file mode 100644 index 000000000..9525a39fb --- /dev/null +++ b/lib/manager/components/deployment/CustomFileEditor.js @@ -0,0 +1,308 @@ +// @flow + +import React, {Component} from 'react' +import { + Button, + ButtonToolbar, + Checkbox, + ControlLabel, + FormControl, + FormGroup, + HelpBlock +} from 'react-bootstrap' + +import type { + CustomFile +} from '../../../types' + +type Props = { + customFile: CustomFile, + customFileEditIdx: null | number, + idx: number, + onCancelEditing: () => void, + onDelete: (number) => void, + onEdit: (number) => void, + onSave: (number, CustomFile) => void +} + +export default class CustomFileEditor extends Component { + constructor (props: Props) { + super(props) + const { customFile } = props + this.state = { + fileSource: customFile.contents ? 'raw' : 'uri', + model: props.customFile + } + } + + _canEdit = () => this.props.customFileEditIdx === null + + /** + * Makes sure that the filename is valid. The filename is optional when a uri + * is provided, but is required when entering raw input. + */ + _fileNameValid = (): boolean => { + const {fileSource, model: customFile} = this.state + return fileSource === 'uri' || Boolean(customFile.filename) + } + + /** + * Makes sure that a uri or some raw input has been added + */ + _hasContents = (): boolean => { + const {model: customFile} = this.state + return Boolean(customFile.contents) || Boolean(customFile.uri) + } + + /** + * Makes sure that the file is used during either graph building, serving or + * both. + */ + _hasSomeUsage = (): boolean => { + const {model: customFile} = this.state + return customFile.useDuringBuild || customFile.useDuringServe + } + + _isEditing= () => this.props.idx === this.props.customFileEditIdx + + _isValidOverall = (): boolean => { + return this._fileNameValid() && this._hasSomeUsage() && this._hasContents() + } + + _onChangeBuildUse = () => { + const { model } = this.state + this.setState({ + model: { + ...model, + useDuringBuild: !model.useDuringBuild + } + }) + } + + _onChangeContents = (evt: SyntheticInputEvent) => { + this.setState({ + model: { + ...this.state.model, + contents: evt.target.value + } + }) + } + + _onChangeFilename = (evt: SyntheticInputEvent) => { + this.setState({ + model: { + ...this.state.model, + filename: evt.target.value + } + }) + } + + _onChangeServeUse = () => { + const { model } = this.state + this.setState({ + model: { + ...model, + useDuringServe: !model.useDuringServe + } + }) + } + + _onChangeSource = (evt: SyntheticInputEvent) => { + const model = {...this.state.model} + // set variable to make flow happy + let newSource + if (evt.target.value === 'raw') { + model.uri = null + newSource = 'raw' + } else { + model.contents = null + newSource = 'uri' + } + this.setState({ + fileSource: newSource, + model + }) + } + + _onChangeUri = (evt: SyntheticInputEvent) => { + this.setState({ + model: { + ...this.state.model, + uri: evt.target.value + } + }) + } + + _onCancelEditing = () => { + const { customFile, onCancelEditing } = this.props + this.setState({ + fileSource: customFile.contents ? 'raw' : 'uri', + model: customFile + }) + onCancelEditing() + } + + _onDelete = () => { + const { idx, onDelete } = this.props + onDelete(idx) + } + + _onEdit = () => { + const { idx, onEdit } = this.props + onEdit(idx) + } + + _onSave = () => { + const { idx, onSave } = this.props + onSave(idx, this.state.model) + } + + _renderToolbar = () => { + const { customFile } = this.props + const isEditing = this._isEditing() + const canEdit = this._canEdit() + const isNewFile = Object.keys(customFile).every(key => !customFile[key]) + return ( + + {canEdit && + + } + {isEditing && !isNewFile && + + } + {isEditing && + + } + + + ) + } + + render () { + const { + fileSource, + model: customFile + } = this.state + const filenameValid = this._fileNameValid() + const hasSomeUsage = this._hasSomeUsage() + const hasContents = this._hasContents() + const isEditing = this._isEditing() + return ( +
      + {this._renderToolbar()} +
      + {isEditing + ? + + {!filenameValid && ( + + Filename must be set when providing raw input! + + )} + + : + Filename:{' '} + + {fileSource === 'raw' + ? customFile.filename + : customFile.filename || '[defaults to filename at end of URI]'} + + + } +
      + + + Use during graph build + + + Use while running server + + {!hasSomeUsage && ( + + File must be used during either graph build or running the server (or both)! + + )} + + + File source + + + + + {!hasContents && Please set contents or uri!} + {fileSource === 'raw' && ( + + )} + {fileSource === 'uri' && ( + + + {!customFile.uri && ( + Enter either a HTTP(S) URL or AWS S3 URI + )} + + )} + +
      + ) + } +} diff --git a/lib/manager/components/deployment/DeploymentConfigurationsPanel.js b/lib/manager/components/deployment/DeploymentConfigurationsPanel.js new file mode 100644 index 000000000..daf9b3c52 --- /dev/null +++ b/lib/manager/components/deployment/DeploymentConfigurationsPanel.js @@ -0,0 +1,286 @@ +// @flow + +import Icon from '@conveyal/woonerf/components/icon' +import fetch from 'isomorphic-fetch' +import React, {Component} from 'react' +import { + Checkbox, + ControlLabel, + ListGroup, + ListGroupItem, + Button, + Panel, + Glyphicon +} from 'react-bootstrap' +import Select from 'react-select' +import validator from 'validator' + +import * as deploymentActions from '../../actions/deployments' +import {getConfigProperty} from '../../../common/util/config' +import CustomConfig from './CustomConfig' +import CustomFileEditor from './CustomFileEditor' + +import type { + CustomFile, + Deployment, + ReactSelectOption +} from '../../../types' + +const TRIP_PLANNER_VERSIONS = [ + { label: 'OTP 1.X', value: 'OTP_1' }, + { label: 'OTP 2.X', value: 'OTP_2' } +] + +function calculateCustomFileKey (customFile: CustomFile, idx: number): string { + const {contents, filename, uri} = customFile + return `${filename || ''}-${contents || ''}-${uri || ''}-${idx}` +} + +export default class DeploymentConfigurationsPanel extends Component<{ + deployment: Deployment, + updateDeployment: typeof deploymentActions.updateDeployment +}, { + customFileEditIdx: null | number, + otp: Array, +}> { + state = { + customFileEditIdx: null, + otp: [] + } + componentDidMount () { + // Fetch the available OTP versions from S3. + this._loadOptions() + } + + /** + * Parse .jar options from an S3 text XML response. + * @param {string} text text response from s3 + * @param {string} key state key under which to store options + */ + _parseOptionsFromXml = (text: string) => { + const parser = new window.DOMParser() + const doc = parser.parseFromString(text, 'application/xml') + + const all = Array.from(doc.querySelectorAll('Contents')) + .map(item => item.querySelector('Key').childNodes[0].nodeValue) // get just key + .filter(item => item !== 'index.html') // don't include the main page + .map(item => item.replace(/.jar$/, '')) // and remove .jar + this.setState({otp: all}) + } + + _loadAndParseOptionsFromXml = (url: string) => { + fetch(url) + .then(res => res.text()) + .then(text => this._parseOptionsFromXml(text)) + } + + /** + * Load .jar options from OTP and R5 S3 buckets. + */ + _loadOptions = () => { + const otpUrl = getConfigProperty('modules.deployment.otp_download_url') || 'https://opentripplanner-builds.s3.amazonaws.com' + this._loadAndParseOptionsFromXml(otpUrl) + } + + _onAddCustomFile = () => { + const { deployment } = this.props + const customFiles = deployment.customFiles + ? [...deployment.customFiles, {}] + : [{}] + this._updateDeployment({ customFiles }) + this.setState({ customFileEditIdx: customFiles.length - 1 }) + } + + _onChangeBuildGraphOnly = () => this._updateDeployment({buildGraphOnly: !this.props.deployment.buildGraphOnly}) + + _onChangeSkipOsmExtract = () => { + const {skipOsmExtract} = this.props.deployment + if (!skipOsmExtract) { + // If changing from including OSM to skipping OSM, verify that this is + // intentional. + if (!window.confirm('Are you sure you want to exclude an OSM extract from the graph build? This will prevent the use of the OSM street network in routing results.')) { + return + } + } + this._updateDeployment({skipOsmExtract: !skipOsmExtract}) + } + + _onCancelEditingCustomFile = () => { + this.setState({ customFileEditIdx: null }) + } + + _onEditCustomFile = (idx: number) => { + this.setState({ customFileEditIdx: idx }) + } + + _onDeleteCustomFile = (idx: number) => { + const { deployment } = this.props + const customFiles = [...deployment.customFiles || []] + customFiles.splice(idx, 1) + this._updateDeployment({ customFiles }) + this.setState({ customFileEditIdx: null }) + } + + _onSaveCustomFile = (idx: number, data: CustomFile) => { + const { deployment } = this.props + const customFiles = [...deployment.customFiles || []] + customFiles[idx] = data + this._updateDeployment({ customFiles }) + this.setState({ customFileEditIdx: null }) + } + + _setOsmUrl = () => { + const currentUrl = this.props.deployment.osmExtractUrl || '' + const osmExtractUrl = window.prompt( + 'Please provide a public URL from which to download an OSM extract (.pbf).', + currentUrl + ) + if (osmExtractUrl) { + if (!validator.isURL(osmExtractUrl)) { + window.alert(`URL ${osmExtractUrl} is invalid!`) + return + } + this._updateDeployment({osmExtractUrl}) + } + } + + _clearOsmUrl = () => this._updateDeployment({osmExtractUrl: null}) + + _onUpdateTripPlannerVersion = (option: ReactSelectOption) => { + const {deployment, updateDeployment} = this.props + updateDeployment(deployment, { tripPlannerVersion: option.value }) + } + + _onUpdateVersion = (option: ReactSelectOption) => { + this._updateDeployment({otpVersion: option.value}) + } + + _updateDeployment = (props: {[string]: any}) => { + const {deployment, updateDeployment} = this.props + updateDeployment(deployment, props) + } + + render () { + const { deployment, updateDeployment } = this.props + const { customFileEditIdx, otp: options } = this.state + return ( + OTP Configuration}> + + + + Build graph only + + Trip Planner Version: + ({value: v, label: v})) : []} + /> +
      + + + Deploying to the{' '} + {deployment.routerId || 'default'}{' '} + OpenTripPlanner router. + + + OpenStreetMap Settings + + Build graph with OSM extract + + {/* Hide URL/auto-extract if skipping OSM extract. */} + {deployment.skipOsmExtract + ? null + : deployment.osmExtractUrl + ?
      + + URL: + + + {deployment.osmExtractUrl} + + + +
      + :
      + Auto-extract OSM (N. America only) + +
      + } +
      + + + + + + + +
      Custom Files
      + {deployment.customFiles && deployment.customFiles.map( + (customFile, idx) => ( + + ) + )} + +
      + + + ) + } +} diff --git a/lib/manager/components/DeploymentConfirmModal.js b/lib/manager/components/deployment/DeploymentConfirmModal.js similarity index 95% rename from lib/manager/components/DeploymentConfirmModal.js rename to lib/manager/components/deployment/DeploymentConfirmModal.js index 6b273ef99..c873f073a 100644 --- a/lib/manager/components/DeploymentConfirmModal.js +++ b/lib/manager/components/deployment/DeploymentConfirmModal.js @@ -4,15 +4,15 @@ import React, {Component, type Node} from 'react' import {Alert as BootstrapAlert, Button, Glyphicon, Modal} from 'react-bootstrap' import {Map, TileLayer, Rectangle} from 'react-leaflet' -import * as deploymentActions from '../actions/deployments' -import {getComponentMessages} from '../../common/util/config' -import {defaultTileLayerProps} from '../../common/util/maps' -import {getServerForId} from '../util/deployment' -import {getFeedNames, versionHasExpired} from '../util/version' +import * as deploymentActions from '../../actions/deployments' +import {getComponentMessages} from '../../../common/util/config' +import {defaultTileLayerProps} from '../../../common/util/maps' +import {getServerForId} from '../../util/deployment' +import {getFeedNames, versionHasExpired} from '../../util/version' import polygon from 'turf-polygon' import area from '@turf/area' -import type {Deployment, Project, SummarizedFeedVersion} from '../../types' +import type {Deployment, Project, SummarizedFeedVersion} from '../../../types' type Props = { deployToTarget: typeof deploymentActions.deployToTarget, diff --git a/lib/manager/components/DeploymentPreviewButton.js b/lib/manager/components/deployment/DeploymentPreviewButton.js similarity index 98% rename from lib/manager/components/DeploymentPreviewButton.js rename to lib/manager/components/deployment/DeploymentPreviewButton.js index 0e62ce2f7..a21a11f09 100644 --- a/lib/manager/components/DeploymentPreviewButton.js +++ b/lib/manager/components/deployment/DeploymentPreviewButton.js @@ -4,7 +4,7 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' import {Button, Tooltip, OverlayTrigger} from 'react-bootstrap' -import type {Deployment, Project} from '../../types' +import type {Deployment, Project} from '../../../types' type Props = { deployment: Deployment, diff --git a/lib/manager/components/DeploymentSettings.js b/lib/manager/components/deployment/DeploymentSettings.js similarity index 78% rename from lib/manager/components/DeploymentSettings.js rename to lib/manager/components/deployment/DeploymentSettings.js index 61be630f2..4ce6a8947 100644 --- a/lib/manager/components/DeploymentSettings.js +++ b/lib/manager/components/deployment/DeploymentSettings.js @@ -1,22 +1,32 @@ // @flow import Icon from '@conveyal/woonerf/components/icon' -import objectPath from 'object-path' +// $FlowFixMe coalesce method is missing in flow type +import {coalesce, get, set} from 'object-path' import React, {Component} from 'react' -import {Row, Col, Button, Panel, Glyphicon, Radio, FormGroup, ControlLabel, FormControl} from 'react-bootstrap' +import { + Button, + Col, + ControlLabel, + FormControl, + FormGroup, + Glyphicon, + Panel, + Radio, + Row +} from 'react-bootstrap' import update from 'react-addons-update' import {shallowEqual} from 'react-pure-render' import {withRouter} from 'react-router' -import {LinkContainer} from 'react-router-bootstrap' -import FormInput from '../../common/components/FormInput' -import {getComponentMessages} from '../../common/util/config' -import CollapsiblePanel from './CollapsiblePanel' -import {parseBounds} from '../util' -import {FIELDS, SERVER_FIELDS, UPDATER_FIELDS} from '../util/deployment' +import FormInput from '../../../common/components/FormInput' +import {getComponentMessages} from '../../../common/util/config' +import CollapsiblePanel from '../CollapsiblePanel' +import {parseBounds} from '../../util' +import {FIELDS, SERVER_FIELDS, UPDATER_FIELDS} from '../../util/deployment' -import {updateProject} from '../actions/projects' -import type {Project} from '../../types' +import {updateProject} from '../../actions/projects' +import type {Project} from '../../../types' type Props = { editDisabled: boolean, @@ -34,29 +44,20 @@ class DeploymentSettings extends Component { messages = getComponentMessages('DeploymentSettings') state = { - buildConfig: objectPath.get(this.props, 'project.buildConfig') || {}, - routerConfig: objectPath.get(this.props, 'project.routerConfig') || {} + buildConfig: get(this.props, 'project.buildConfig') || {}, + routerConfig: get(this.props, 'project.routerConfig') || {} } componentWillReceiveProps (nextProps) { if (nextProps.project.lastUpdated !== this.props.project.lastUpdated) { // Reset state using project data if it is updated. this.setState({ - buildConfig: objectPath.get(nextProps, 'project.buildConfig') || {}, - routerConfig: objectPath.get(nextProps, 'project.routerConfig') || {} + buildConfig: get(nextProps, 'project.buildConfig') || {}, + routerConfig: get(nextProps, 'project.routerConfig') || {} }) } } - componentDidMount () { - // FIXME: This is broken. Check for edits does not always return correct value. - // this.props.router.setRouteLeaveHook(this.props.route, () => { - // if (!this._noEdits()) { - // return 'You have unsaved information, are you sure you want to leave this page?' - // } - // }) - } - _clearBuildConfig = () => { this.props.updateProject(this.props.project.id, {buildConfig: {}}) } @@ -72,7 +73,7 @@ class DeploymentSettings extends Component { if (item) { const stateUpdate = {} item.effects && item.effects.forEach(e => { - objectPath.set(stateUpdate, `${e.key}.$set`, e.value) + set(stateUpdate, `${e.key}.$set`, e.value) }) switch (item.type) { case 'checkbox': @@ -96,19 +97,19 @@ class DeploymentSettings extends Component { _onChangeCheckbox = (evt, stateUpdate = {}, index = null) => { const name = index !== null ? evt.target.name.replace('$index', `${index}`) : evt.target.name - objectPath.set(stateUpdate, `${name}.$set`, evt.target.checked) + set(stateUpdate, `${name}.$set`, evt.target.checked) this.setState(update(this.state, stateUpdate)) } _onChangeSplit = (evt, stateUpdate = {}, index = null) => { const name = index !== null ? evt.target.name.replace('$index', `${index}`) : evt.target.name - objectPath.set(stateUpdate, `${name}.$set`, evt.target.value.split(',')) + set(stateUpdate, `${name}.$set`, evt.target.value.split(',')) this.setState(update(this.state, stateUpdate)) } _onAddUpdater = () => { const stateUpdate = {} - objectPath.set(stateUpdate, + set(stateUpdate, `routerConfig.updaters.$${this.state.routerConfig.updaters ? 'push' : 'set'}`, [{type: '', url: '', frequencySec: 30, sourceType: '', defaultAgencyId: ''}] ) @@ -117,7 +118,7 @@ class DeploymentSettings extends Component { _onRemoveUpdater = (index) => { const stateUpdate = {} - objectPath.set(stateUpdate, `routerConfig.updaters.$splice`, [[index, 1]]) + set(stateUpdate, `routerConfig.updaters.$splice`, [[index, 1]]) this.setState(update(this.state, stateUpdate)) } @@ -125,19 +126,19 @@ class DeploymentSettings extends Component { const name = index !== null ? evt.target.name.replace('$index', `${index}`) : evt.target.name // If value is empty string or undefined, set to null in settings object. // Otherwise, certain fields (such as 'fares') would cause issues with OTP. - objectPath.set(stateUpdate, `${name}.$set`, evt.target.value || null) + set(stateUpdate, `${name}.$set`, evt.target.value || null) this.setState(update(this.state, stateUpdate)) } _onChangeNumber = (evt, stateUpdate = {}, index = null) => { const name = index !== null ? evt.target.name.replace('$index', `${index}`) : evt.target.name - objectPath.set(stateUpdate, `${name}.$set`, +evt.target.value) + set(stateUpdate, `${name}.$set`, +evt.target.value) this.setState(update(this.state, stateUpdate)) } _onSelectBool = (evt, stateUpdate = {}, index = null) => { const name = index !== null ? evt.target.name.replace('$index', `${index}`) : evt.target.name - objectPath.set(stateUpdate, `${name}.$set`, (evt.target.value === 'true')) + set(stateUpdate, `${name}.$set`, (evt.target.value === 'true')) this.setState(update(this.state, stateUpdate)) } @@ -149,7 +150,7 @@ class DeploymentSettings extends Component { // check for conditional render, e.g. elevationBucket is dependent on fetchElevationUS if (f.condition) { const {key, value} = f.condition - const val = objectPath.get(state, `${key}`) + const val = get(state, `${key}`) if (val !== value) return false } return true @@ -157,7 +158,7 @@ class DeploymentSettings extends Component { return ( { }) } - _onSave = (evt) => this.props.updateProject(this.props.project.id, this.state) + _onSave = (evt) => this.props.updateProject(this.props.project.id, this.state, true) _onToggleCustomBounds = (evt) => { const stateUpdate = { useCustomOsmBounds: { $set: (evt.target.value === 'true') } } @@ -189,6 +190,12 @@ class DeploymentSettings extends Component { } } + /** + * Get value for key from state or, if undefined, default to project property + * from props. + */ + _getValue = (key) => coalesce(this.state, [key], this.props.project[key]) + /** * Determine if deployment settings have been modified by checking that every * item in the state matches the original object found in the project object. @@ -198,22 +205,17 @@ class DeploymentSettings extends Component { .every(key => shallowEqual(this.state[key], this.props.project[key])) render () { - const updaters = objectPath.get(this.state, 'routerConfig.updaters') || [] + const updaters = get(this.state, 'routerConfig.updaters') || [] const {project, editDisabled} = this.props return (
      - - - {/* Build config settings */} {' '} {this.messages('buildConfig.title')} @@ -227,7 +229,7 @@ class DeploymentSettings extends Component { {' '} {this.messages('routerConfig.title')} @@ -278,14 +280,14 @@ class DeploymentSettings extends Component { {this.messages('osm.gtfs')} {this.messages('osm.custom')} diff --git a/lib/manager/components/deployment/DeploymentTableRow.js b/lib/manager/components/deployment/DeploymentTableRow.js new file mode 100644 index 000000000..90cf44cf5 --- /dev/null +++ b/lib/manager/components/deployment/DeploymentTableRow.js @@ -0,0 +1,197 @@ +// @flow + +import Icon from '@conveyal/woonerf/components/icon' +import React, {Component} from 'react' +import {Button, Badge, Glyphicon} from 'react-bootstrap' +import {connect} from 'react-redux' +import { Link } from 'react-router' +import Select from 'react-select' + +import * as deploymentActions from '../../actions/deployments' +import {formatTimestamp, fromNow} from '../../../common/util/date-time' +import { + versionHasExpired, + versionHasNotBegun, + versionIsPinnedInDeployment +} from '../../util/version' + +import type { + Deployment, + Feed, + Project, + SummarizedFeedVersion +} from '../../../types' +import type {AppState} from '../../../types/reducers' + +function addClassIfFalsy (val, className) { + return !val ? ` ${className}` : '' +} + +function warnIfFalsy (val) { + return addClassIfFalsy(val, 'warning') +} + +function dangerIfTruthy (val) { + return addClassIfFalsy(!val, 'danger') +} + +type RowProps = { + deployment: Deployment, + feedSource: Feed, + project: Project, + version: SummarizedFeedVersion +} + +type ConnectedRowProps = RowProps & { + deleteFeedVersion: typeof deploymentActions.deleteFeedVersion, + feedVersionPinned: boolean, + isVersionEditable: boolean, + togglePinFeedVersion: typeof deploymentActions.togglePinFeedVersion, + updateVersionForFeedSource: typeof deploymentActions.updateVersionForFeedSource, + userCanEditDeployment: boolean, + userCanRemoveVersion: boolean +} + +class DeploymentTableRow extends Component { + _numberToOption = n => ({value: n, label: n}) + + _onChangeVersion = option => { + const { + deployment, + feedSource, + updateVersionForFeedSource + } = this.props + const versionToAdd = {feedSourceId: feedSource.id, version: option.value} + updateVersionForFeedSource(deployment, feedSource, versionToAdd) + } + + _togglePinFeedVersion = () => { + const {togglePinFeedVersion, deployment, version} = this.props + togglePinFeedVersion(deployment, version) + } + + _removeVersion = () => { + const {deleteFeedVersion, deployment, version} = this.props + deleteFeedVersion(deployment, version) + } + + render () { + const { + feedSource, + feedVersionPinned, + isVersionEditable, + userCanEditDeployment, + userCanRemoveVersion, + version + } = this.props + const {latestVersionId} = feedSource + const {validationResult: result} = version + const expired = versionHasExpired(version) + const future = versionHasNotBegun(version) + return ( + + + {feedSource.name} + {feedVersionPinned && } + + +
      + +
      + ({label: d.name, value: d.id}))} + placeholder={this.messages('pinnedDeployment.placeholder')} + value={project.pinnedDeploymentId} + /> + {this.messages('pinnedDeployment.help')} + + + + {' '} + {this.messages('autoDeploy.label')} + +