diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1b1fae7bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +.git/ +.DS_Store \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 120000 index 000000000..b4c3c4797 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1 @@ +node_modules/mastarm/lib/eslintrc.json \ No newline at end of file diff --git a/.flowconfig b/.flowconfig index af233d406..71a459f73 100644 --- a/.flowconfig +++ b/.flowconfig @@ -7,6 +7,7 @@ .*/node_modules/nock/node_modules/changelog/examples/.* .*/node_modules/npmconf/.* .*/node_modules/react-leaflet/src/.* +.*/node_modules/react-leaflet/lib/*.*\.js\.flow .*/node_modules/reqwest/.* .*/node_modules/module-deps/test/invalid_pkg/package.json .*/node_modules/immutable/dist/immutable.js.flow diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a4e783c83..27fe2f34c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ # See https://help.github.com/articles/about-codeowners/ -# An IBI Group OTP/Data Tools member is required to approve PR merges +# An Arcadis OTP/TRANSIT-data-tools member is required to approve PR merges * @ibi-group/otp-data-tools diff --git a/.github/issue_template.md b/.github/issue_template.md index 6fd56471c..80c8c2137 100644 --- a/.github/issue_template.md +++ b/.github/issue_template.md @@ -1,4 +1,4 @@ -_**NOTE:** This issue system is intended for reporting bugs and tracking progress in software development. Although this software is licensed with an open-source license, any issue opened here may not be dealt with in a timely manner. [IBI Group](https://www.ibigroup.com/) is able to provide technical support for custom deployments of this software. Please contact [Ritesh Warade](mailto:ritesh.warade@ibigroup.com?subject=Data%20Tools%20inquiry%20via%20GitHub&body=Name:%20%0D%0AAgency/Company:%20%0D%0ABest%20date/time%20for%20a%20demo/discussion:%20%0D%0ADescription%20of%20needs:%20) if your company or organization is interested in opening a support contract with us. Please remove this note when creating the issue._ +_**NOTE:** This issue system is intended for reporting bugs and tracking progress in software development. Although this software is licensed with an open-source license, any issue opened here may not be dealt with in a timely manner. [Arcadis IBI Group](https://www.ibigroup.com/) is able to provide technical support for custom deployments of this software. Please contact [Jon Campbell](mailto:jon.campbell@ibigroup.com?subject=Data%20Tools%20inquiry%20via%20GitHub&body=Name:%20%0D%0AAgency/Company:%20%0D%0ABest%20date/time%20for%20a%20demo/discussion:%20%0D%0ADescription%20of%20needs:%20) if your company or organization is interested in opening a support contract with us. Please remove this note when creating the issue._ ## Observed behavior (please include a screenshot if possible) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..ad05c4b89 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,74 @@ +# This file was generated by GitHub + +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "dev", master, "mtc-deploy" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "dev", "mtc-deploy" ] + schedule: + - cron: '24 19 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 258b9e2f4..a5704d2ad 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -1,49 +1,32 @@ name: Node.js CI -on: [push, pull_request] - +on: + workflow_call: + inputs: + e2e: + required: true + type: boolean + 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 + - name: Install mkdocs and plugins run: | - pip install mkdocs + pip install Jinja2==3.0.3 mkdocs mkdocs --version - - name: Use Node.js 12.x + pip install mkdocs-macros-plugin + - name: Use Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.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 @@ -56,49 +39,46 @@ jobs: 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: Whether e2e should run + run: echo Running e2e - {{ inputs.e2e }} + - name: Write .env file for Docker + if: inputs.e2e + run: | + touch __tests__/e2e/.env + echo BUGSNAG_KEY= ${{ secrets.BUGSNAG_KEY }} >> __tests__/e2e/.env + echo AUTH0_API_CLIENT= ${{ secrets.AUTH0_API_CLIENT }} >> __tests__/e2e/.env + echo AUTH0_API_SECRET= ${{ secrets.AUTH0_API_SECRET }} >> __tests__/e2e/.env + echo AUTH0_CLIENT_ID= ${{ secrets.AUTH0_CLIENT_ID }} >> __tests__/e2e/.env + echo AUTH0_PUBLIC_KEY=/datatools/datatools.pem >> __tests__/e2e/.env + echo AUTH0_CONNECTION_NAME= ${{ secrets.AUTH0_CONNECTION_NAME }} >> __tests__/e2e/.env + echo AUTH0_DOMAIN= ${{ secrets.AUTH0_DOMAIN }} >> __tests__/e2e/.env + echo AWS_ACCESS_KEY_ID= ${{ secrets.AWS_ACCESS_KEY_ID }} >> __tests__/e2e/.env + echo AWS_REGION=us-east-1 >> __tests__/e2e/.env + echo AWS_SECRET_ACCESS_KEY= ${{ secrets.AWS_SECRET_ACCESS_KEY }} >> __tests__/e2e/.env + echo GITHUB_REF_SLUG= $GITHUB_REF >> __tests__/e2e/.env + echo GITHUB_SHA= $GITHUB_SHA >> __tests__/e2e/.env + echo E2E_AUTH0_PASSWORD= ${{ secrets.E2E_AUTH0_PASSWORD }} >> __tests__/e2e/.env + echo E2E_AUTH0_USERNAME= ${{ secrets.E2E_AUTH0_USERNAME }} >> __tests__/e2e/.env + echo GRAPH_HOPPER_KEY= ${{ secrets.GRAPH_HOPPER_KEY }} >> __tests__/e2e/.env + echo LOGS_S3_BUCKET= ${{ secrets.LOGS_S3_BUCKET }} >> __tests__/e2e/.env + echo MAPBOX_ACCESS_TOKEN= ${{ secrets.MAPBOX_ACCESS_TOKEN }} >> __tests__/e2e/.env + echo MS_TEAMS_WEBHOOK_URL= ${{ secrets.MS_TEAMS_WEBHOOK_URL }} >> __tests__/e2e/.env + echo OSM_VEX= ${{ secrets.OSM_VEX }} >> __tests__/e2e/.env + echo RUN_E2E= "true" >> __tests__/e2e/.env + echo S3_BUCKET= ${{ secrets.S3_BUCKET }} >> __tests__/e2e/.env + echo SPARKPOST_EMAIL= ${{ secrets.SPARKPOST_EMAIL }} >> __tests__/e2e/.env + echo SPARKPOST_KEY= ${{ secrets.SPARKPOST_KEY }} >> __tests__/e2e/.env + echo TRANSITFEEDS_KEY= ${{ secrets.TRANSITFEEDS_KEY }} >> __tests__/e2e/.env + touch __tests__/e2e/server/datatools.pem + echo ${{ secrets.AUTH0_DATATOOLS_PEM }} | base64 --decode > __tests__/e2e/server/datatools.pem + - 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 }} + if: inputs.e2e + run: | + (echo "E2E Test Attempt 1" && docker compose --env-file __tests__/e2e/.env -f __tests__/e2e/docker-compose.yml up --abort-on-container-exit) || \ + (echo "E2E Test Attempt 2" && docker compose --env-file __tests__/e2e/.env -f __tests__/e2e/docker-compose.yml up --abort-on-container-exit) || \ + (echo "E2E Test Attempt 3" && docker compose --env-file __tests__/e2e/.env -f __tests__/e2e/docker-compose.yml up --abort-on-container-exit) || \ + (echo "E2E Tests Failed" && exit 1) # At this point, the build is successful. - name: Semantic Release env: diff --git a/.github/workflows/pull-req.yml b/.github/workflows/pull-req.yml new file mode 100644 index 000000000..b3844939f --- /dev/null +++ b/.github/workflows/pull-req.yml @@ -0,0 +1,27 @@ +name: PR E2E Checks + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] +jobs: + pr-e2e-check: + # Run e2e tests: + # - on pull request changed from draft to ready-for-review + # - on commits to: + # * PRs to master + # * PRs to dev that are not draft + # * PRs to dev on branches created by dependabot + if: "${{ + github.event_name == 'pull_request' && ( + github.event.action == 'review_requested' || + github.base_ref == 'master' || + (github.base_ref == 'dev' && ( + github.event.pull_request.draft == false || + startsWith(github.head_ref, 'dependabot/') + )) + ) + }}" + uses: ./.github/workflows/node-ci.yml + with: + e2e: true + secrets: inherit diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 000000000..6b6ad99cb --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,11 @@ +name: Commit Checks + +on: [push] + +jobs: + push-checks: + uses: ./.github/workflows/node-ci.yml + with: + # Run e2e tests on pushes to dev and master. + e2e: ${{ github.ref_name == 'dev' || github.ref_name == 'master' }} + secrets: inherit diff --git a/.gitignore b/.gitignore index d747e7d3a..a0310431d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ tmp/ .tags *.pid +# Ignore mobility data jar for building rules +*.jar + # Optional npm cache directory .npm @@ -19,6 +22,7 @@ configurations/* !configurations/default !configurations/test !configurations/end-to-end +!configurations/mtc-docs dist assets @@ -30,7 +34,9 @@ env.yml env.yml-original .env !configurations/test/env.yml +!docker/server/env.yml scripts/*client.json +*.pem # Vs code settings .vscode/ diff --git a/.readthedocs.yml b/.readthedocs.yaml similarity index 69% rename from .readthedocs.yml rename to .readthedocs.yaml index e138fa82d..76180a229 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yaml @@ -8,7 +8,12 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.13" + +# Install mkdocs macros and other plugins +python: + install: + - requirements: docs/requirements.txt mkdocs: configuration: mkdocs.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index f254d1d32..ff1dadf85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # CHANGELOG ---------------------- +## Next + * Support German language and add initial translations. +## Former * Fix timetable previous stop time checks from checking text columns. * Add feature allowing routing to avoid highways. * Support Polish language and add initial translations. diff --git a/README.md b/README.md index 58a9cf9d2..b991f1123 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ # datatools-ui -The core application for IBI Group's transit Data Tools suite. This application provides GTFS editing, management, validation, and deployment to OpenTripPlanner. +[![Join the chat at https://matrix.to/#/#transit-data-tools:gitter.im](https://badges.gitter.im/repo.png)](https://matrix.to/#/#transit-data-tools:gitter.im) + +The core application for IBI Group's TRANSIT-Data-Tools suite. This application provides GTFS editing, management, validation, and deployment to OpenTripPlanner. + +## Quick Start + +A pre-configured datatools instance can be lauched via Docker by running + +```bash +cd docker +cp ../configurations/default/env.yml.tmp ../configurations/default/env.yml +docker compose up +``` + +from the datatools-ui directory. Datatools will then be running on port `9966`. + +Deployment functionality will not work, and persistence may only work in certain cases (look into Docker volumes for more info). ## Configuration @@ -12,6 +28,10 @@ View the [latest release documentation](http://data-tools-docs.ibi-transit.com/e Note: `dev` branch docs (which refer to the default `branch` and are more up-to-date and accurate for most users) can be found [here](http://data-tools-docs.ibi-transit.com/en/dev/). +## Getting in touch + +We have a Gitter [space](https://matrix.to/#/#transit-data-tools:gitter.im) for the full TRANSIT-Data-Tools project where you can post questions and comments. + ## Shoutouts 🙏 BrowserStack Logo diff --git a/__mocks__/auth0-js.js b/__mocks__/auth0-js.js deleted file mode 100644 index 0eeb36d26..000000000 --- a/__mocks__/auth0-js.js +++ /dev/null @@ -1,41 +0,0 @@ -import jwt from 'jsonwebtoken' - -module.exports = { - WebAuth: class WebAuth { - constructor ({domain, clientID}) { - if (!domain) { - throw new Error('Domain required') - } - if (!clientID) { - throw new Error('Client ID required') - } - } - - renewAuth ( - { - audience, - nonce, - postMessageDataType, - redirectUri, - scope, - usePostMessage - }, - callback - ) { - return callback(null, { - accessToken: jwt.sign( - { - nonce - }, - 'signingKey' - ), - idToken: jwt.sign( - { - nonce - }, - 'signingKey' - ) - }) - } - } -} diff --git a/__mocks__/auth0-lock.js b/__mocks__/auth0-lock.js deleted file mode 100644 index 59313dab7..000000000 --- a/__mocks__/auth0-lock.js +++ /dev/null @@ -1,3 +0,0 @@ -// TODO: remove. There was an import issue, so this is a temporary hack. -// perhaps a later version of Auth0 will have negate the need for this file. -module.exports = function () {} diff --git a/__tests__/e2e/Dockerfile b/__tests__/e2e/Dockerfile new file mode 100644 index 000000000..2e252b42b --- /dev/null +++ b/__tests__/e2e/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +FROM public.ecr.aws/s2a5w2n9/puppeteer:latest +WORKDIR /datatools-ui + +USER root +RUN apk add --no-cache git + +RUN yarn global add https://github.com/ibi-group/otp-runner.git +RUN yarn global add miles-grant-ibigroup/mastarm#f61ca541a788e8cae8a0e32b886de754846ea16f + +COPY package.json yarn.lock /datatools-ui/ +RUN yarn +COPY . /datatools-ui/ + +RUN mkdir -p /opt/otp +RUN mkdir -p /datatools-ui/e2e-test-results/ +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 + +RUN wget https://raw.githubusercontent.com/ettore26/wait-for-command/master/wait-for-command.sh +RUN chmod +x ./wait-for-command.sh + +ENV TEST_FOLDER_PATH=/datatools-ui/e2e-test-results +ENV IS_DOCKER=true \ No newline at end of file diff --git a/__tests__/e2e/docker-compose.yml b/__tests__/e2e/docker-compose.yml new file mode 100644 index 000000000..f83daa7dd --- /dev/null +++ b/__tests__/e2e/docker-compose.yml @@ -0,0 +1,88 @@ +version: '3.8' + +x-common-variables: &common-variables + - BUGSNAG_KEY=${BUGSNAG_KEY} + - E2E_AUTH0_USERNAME=${E2E_AUTH0_USERNAME:?err} + - E2E_AUTH0_PASSWORD=${E2E_AUTH0_PASSWORD:?err} + - S3_BUCKET=${S3_BUCKET:?err} + - LOGS_S3_BUCKET=${LOGS_S3_BUCKET} + - MS_TEAMS_WEBHOOK_URL=${MS_TEAMS_WEBHOOK_URL} + - MAPBOX_ACCESS_TOKEN=${MAPBOX_ACCESS_TOKEN} + - GITHUB_SHA=${GITHUB_SHA} + - GITHUB_REF_SLUG=${GITHUB_REF_SLUG} + - TRANSITFEEDS_KEY=${TRANSITFEEDS_KEY} + - GITHUB_REPOSITORY=${GITHUB_REPOSITORY} + - GITHUB_WORKSPACE=${GITHUB_WORKSPACE} + - GITHUB_RUN_ID=${GITHUB_RUN_ID} + - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID:?err} + - AUTH0_PUBLIC_KEY=${AUTH0_PUBLIC_KEY:?err} + - AUTH0_CONNECTION_NAME=${AUTH0_CONNECTION_NAME:?err} + - AUTH0_DOMAIN=${AUTH0_DOMAIN:?err} + - AUTH0_API_CLIENT=${AUTH0_API_CLIENT:?err} + - AUTH0_API_SECRET=${AUTH0_API_SECRET:?err} + - OSM_VEX=${OSM_VEX} + - SPARKPOST_KEY=${SPARKPOST_KEY} + - SPARKPOST_EMAIL=${SPARKPOST_EMAIL} + - GTFS_DATABASE_URL=jdbc:postgresql://postgres/dmtest + - GTFS_DATABASE_USER=root + - GTFS_DATABASE_PASSWORD=e2e + - MONGO_DB_NAME=data_manager + - MONGO_HOST=mongo:27017 + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:?err} + - AWS_REGION=${AWS_REGION:?err} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:?err} + +services: + datatools-e2e-tests: + build: + context: ../../ + args: *common-variables + dockerfile: ./__tests__/e2e/Dockerfile + + command: /bin/sh -c "./wait-for-command.sh -t 15 -c 'nc -z datatools-ui 9966' && sleep 25 && yarn test-end-to-end" + shm_size: '2gb' + cap_add: + - ALL + depends_on: + - "datatools-server" + - "datatools-ui" + - "datatools-ui-proxy" + - "mongo" + - "postgres" + environment: *common-variables + + mongo: + image: mongo + restart: always + datatools-server: + build: + context: ../../ + dockerfile: ./__tests__/e2e/server/Dockerfile + args: *common-variables + restart: always + environment: *common-variables + ports: + - "4000:4000" + datatools-ui-proxy: + build: + context: ../../ + dockerfile: ./__tests__/e2e/ui-proxy/Dockerfile + ports: + - "443:443" + datatools-ui: + build: + context: ../../ + dockerfile: ./__tests__/e2e/ui/Dockerfile + args: *common-variables + restart: always + environment: *common-variables + ports: + - "9966:9966" + postgres: + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_USER: root + POSTGRES_PASS: e2e + POSTGRES_DB: dmtest + image: postgres + restart: always \ No newline at end of file diff --git a/__tests__/e2e/puppeteer/Dockerfile b/__tests__/e2e/puppeteer/Dockerfile new file mode 100644 index 000000000..9a6aa84d7 --- /dev/null +++ b/__tests__/e2e/puppeteer/Dockerfile @@ -0,0 +1,28 @@ +FROM alpine + +# Installs latest Chromium (100) package. +RUN apk add --no-cache \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + nodejs \ + yarn + +# Tell Puppeteer to skip installing Chrome. We'll be using the installed package. +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser + +# Puppeteer v13.5.0 works with Chromium 100. +RUN yarn add puppeteer + +# Add user so we don't need --no-sandbox. +RUN addgroup -S pptruser && adduser -S -G pptruser pptruser \ + && mkdir -p /home/pptruser/Downloads /app \ + && chown -R pptruser:pptruser /home/pptruser \ + && chown -R pptruser:pptruser /app + +# Run everything after as non-privileged user. +USER pptruser \ No newline at end of file diff --git a/__tests__/e2e/server/Dockerfile b/__tests__/e2e/server/Dockerfile new file mode 100644 index 000000000..68b7af3e6 --- /dev/null +++ b/__tests__/e2e/server/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +FROM maven:3.8.7-openjdk-18 + +WORKDIR /datatools + +ARG E2E_AUTH0_USERNAME +ARG E2E_AUTH0_PASSWORD +ARG E2E_S3_BUCKET +ARG MS_TEAMS_WEBHOOK_URL +ARG GITHUB_REF_SLUG +ARG GITHUB_SHA +ARG TRANSITFEEDS_KEY +ARG GITHUB_REPOSITORY +ARG GITHUB_WORKSPACE +ARG GITHUB_RUN_ID +ARG AUTH0_CLIENT_ID +ARG AUTH0_PUBLIC_KEY +ARG AUTH0_DOMAIN +ARG AUTH0_CONNECTION_NAME +ARG AUTH0_API_CLIENT +ARG AUTH0_API_SECRET +ARG OSM_VEX +ARG SPARKPOST_KEY +ARG SPARKPOST_EMAIL +ARG GTFS_DATABASE_URL +ARG GTFS_DATABASE_USER +ARG GTFS_DATABASE_PASSWORD +ARG MONGO_DB_NAME +ARG MONGO_HOST +ARG AWS_ACCESS_KEY_ID +ARG AWS_REGION +ARG AWS_SECRET_ACCESS_KEY + +# Grab latest dev build of Datatools Server +RUN git clone https://github.com/ibi-group/datatools-server.git +RUN microdnf install wget +WORKDIR /datatools/datatools-server + +RUN mvn package -DskipTests +RUN cp target/dt*.jar ./datatools-server-3.8.1-SNAPSHOT.jar + +# Grab latest dev build of OTP +RUN wget https://repo1.maven.org/maven2/org/opentripplanner/otp/1.4.0/otp-1.4.0-shaded.jar +RUN mkdir -p /tmp/otp/graphs +RUN mkdir -p /var/datatools_gtfs + +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 + +# Grab server config +RUN mkdir /config +RUN wget https://raw.githubusercontent.com/ibi-group/datatools-server/dev/configurations/default/server.yml.tmp -O /config/server.yml + +# The enviornment variables contain everything needed on the server +COPY __tests__/e2e/server/datatools.pem /datatools/ +RUN touch /config/env.yml +RUN env | sed 's/\=/\: /' > /config/env.yml + +COPY __tests__/e2e/server/launch.sh launch.sh +RUN chmod +x launch.sh +CMD ./launch.sh +EXPOSE 8080 +EXPOSE 4000 diff --git a/__tests__/e2e/server/launch.sh b/__tests__/e2e/server/launch.sh new file mode 100644 index 000000000..63fdc2870 --- /dev/null +++ b/__tests__/e2e/server/launch.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Start the first process +java -jar otp-1.4.0-shaded.jar --server --autoScan --basePath /tmp/otp --insecure --router default & + +# Start the second process +java -jar datatools-server-3.8.1-SNAPSHOT.jar /config/env.yml /config/server.yml & + +# Wait for any process to exit +wait -n + +# Exit with status of process that exited first +exit $? \ No newline at end of file diff --git a/__tests__/e2e/ui-proxy/Caddyfile b/__tests__/e2e/ui-proxy/Caddyfile new file mode 100644 index 000000000..41b5d29a0 --- /dev/null +++ b/__tests__/e2e/ui-proxy/Caddyfile @@ -0,0 +1,4 @@ +datatools-ui-proxy { + reverse_proxy datatools-ui:9966 + tls internal +} diff --git a/__tests__/e2e/ui-proxy/Dockerfile b/__tests__/e2e/ui-proxy/Dockerfile new file mode 100644 index 000000000..e8e7ecf90 --- /dev/null +++ b/__tests__/e2e/ui-proxy/Dockerfile @@ -0,0 +1,3 @@ +FROM caddy:latest +COPY ./__tests__/e2e/ui-proxy/Caddyfile /etc/caddy/Caddyfile +EXPOSE 443 \ No newline at end of file diff --git a/__tests__/e2e/ui/Dockerfile b/__tests__/e2e/ui/Dockerfile new file mode 100644 index 000000000..122ffa3b3 --- /dev/null +++ b/__tests__/e2e/ui/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1 +FROM node:14 +WORKDIR /datatools-build + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true +ARG BUGSNAG_KEY + +RUN cd /datatools-build +COPY package.json yarn.lock patches /datatools-build/ +RUN yarn +COPY . /datatools-build/ +COPY configurations/default /datatools-config/ +CMD yarn run mastarm build --env dev --serve --proxy http://datatools-server:4000/api # \ No newline at end of file diff --git a/__tests__/end-to-end.js b/__tests__/end-to-end.js index 7b0280bdd..0f4eba539 100644 --- a/__tests__/end-to-end.js +++ b/__tests__/end-to-end.js @@ -4,16 +4,15 @@ import path from 'path' import fs from 'fs-extra' import fetch from 'isomorphic-fetch' -import {safeLoad} from 'js-yaml' import md5File from 'md5-file/promise' import moment from 'moment' import SimpleNodeLogger from 'simple-node-logger' import uuidv4 from 'uuid/v4' +// $FlowFixMe we rely on puppeteer being imported externally, as the latest version conflicts with mastarm +import puppeteer from 'puppeteer' +import { PuppeteerScreenRecorder } from 'puppeteer-screen-recorder' -import {collectingCoverage, getTestFolderFilename, isCi} from './test-utils/utils' - -// not imported because of weird flow error -const puppeteer = require('puppeteer') +import {collectingCoverage, getTestFolderFilename, isCi, isDocker} from './test-utils/utils' // if the ISOLATED_TEST is defined, only the specifed test (and any dependet // tests) will be ran and all others will be skipped. @@ -23,12 +22,13 @@ const ISOLATED_TEST = null // null means run all tests // TODO: Allow the below options (puppeteer and test) to be enabled via command // line options parsed by mastarm. const puppeteerOptions = { - headless: isCi, + // dumpio: true, // dumps all browser console to docker logs + headless: isCi || isDocker, // The following options can be enabled manually to help with debugging. // dumpio: true, // Logs all of browser console to stdout - // slowMo: 30 // puts xx milliseconds between events (for easier watching in non-headless) + // slowMo: 50, // puts xx milliseconds between events (for easier watching in non-headless) // NOTE: In order to run on Travis CI, use args --no-sandbox option - args: isCi ? ['--no-sandbox'] : [] + args: isCi || isDocker ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--ignore-certificate-errors'] : [] } const testOptions = { // If enabled, failFast will break out of the test script immediately. @@ -36,14 +36,12 @@ const testOptions = { } let failingFast = false let successfullyCreatedTestProject = false -let config: { - password: string, - username: string -} let browser let page +let recorder +let cdpSession const gtfsUploadFile = './configurations/end-to-end/test-gtfs-to-upload.zip' -const OTP_ROOT = 'http://localhost:8080/otp/routers/' +const OTP_ROOT = 'http://datatools-server:8080/otp/routers/' const testTime = moment().format() const fileSafeTestTime = moment().format('YYYY-MM-DDTHH-mm-ss') const testProjectName = `test-project-${testTime}` @@ -245,9 +243,18 @@ async function sendCoverageToServer () { * Expect the innerHTML obtained from the given selector to contain the * given string. */ -async function expectSelectorToContainHtml (selector: string, html: string) { - const innerHTML = await getInnerHTMLFromSelector(selector) - expect(innerHTML).toContain(html) +async function expectSelectorToContainHtml (selector: string, html: string, retry: ?boolean) { + try { + const innerHTML = await getInnerHTMLFromSelector(selector) + expect(innerHTML).toContain(html) + } catch { + // Some parts of datatools can sometimes get stuck and need a refresh + if (!retry) { + log.warn('failed to find selector on problematic page, attempting page reload before retrying') + await page.reload({ waitUntil: 'networkidle0' }) + await expectSelectorToContainHtml(selector, html, true) + } + } } /** @@ -279,19 +286,22 @@ async function expectFeedVersionValidityDates (startDate: string, endDate: strin */ async function createProject (projectName: string) { log.info(`creating project with name: ${projectName}`) + await wait(3000) await click('#context-dropdown') + await wait(5000) await waitForAndClick('a[href="/project/new"]') + await wait(3000) await waitForSelector('[data-test-id="project-name-input-container"]') await type('[data-test-id="project-name-input-container"] input', projectName) await click('[data-test-id="project-settings-form-save-button"]') log.info('saving new project') - await wait(2000, 'for project to get saved') + await wait(3500, 'for project to get saved') // verify that the project was created with the proper name await expectSelectorToContainHtml('.project-header', projectName) // go back to project list - await goto('http://localhost:9966/project', {waitUntil: 'networkidle0'}) + await goto('https://datatools-ui-proxy/project', {waitUntil: 'networkidle0'}) // verify the new project is listed in the project list await expectSelectorToContainHtml('[data-test-id="project-list-table"]', projectName) @@ -304,7 +314,7 @@ async function createProject (projectName: string) { async function deleteProject (projectId: string) { log.info(`deleting project with id: ${projectId}`) // navigate to that project's settings - await goto(`http://localhost:9966/project/${projectId}/settings`) + await goto(`https://datatools-ui-proxy/project/${projectId}/settings`) // delete that project await waitForAndClick('[data-test-id="delete-project-button"]') @@ -313,7 +323,8 @@ async function deleteProject (projectId: string) { log.info('deleted project') // verify deletion - await goto(`http://localhost:9966/project/${projectId}`) + await goto(`https://datatools-ui-proxy/project/${projectId}`) + await wait(3000, 'for project page to load') await waitForSelector('.project-not-found') await wait(5000, 'for previously rendered project markup to be removed') await expectSelectorToContainHtml('.project-not-found', projectId) @@ -393,7 +404,7 @@ async function createFeedSourceViaProjectHeaderButton (feedSourceName) { log.info(`create Feed Source with name: ${feedSourceName} via project header button`) // go to project page await goto( - `http://localhost:9966/project/${testProjectId}`, + `https://datatools-ui-proxy/project/${testProjectId}`, { waitUntil: 'networkidle0' } @@ -517,7 +528,7 @@ async function createStop ({ // save await click('[data-test-id="save-entity-button"]') - await wait(2000, 'for save to happen') + await wait(5000, 'for save to happen') log.info(`created stop with name: ${name}`) } @@ -527,6 +538,7 @@ async function createStop ({ * @param {string} searchText the text to enter into the search input */ async function filterUsers (searchText: string) { + await wait(8000, 'for user list to load') // type in text await type('[data-test-id="search-user-input"]', searchText) @@ -604,15 +616,18 @@ function formatSecondsElapsed (startTime: number) { */ async function waitAndClearCompletedJobs () { const startTime = new Date() - // wait for jobs to get completed - await wait(500, 'for job monitoring to begin') - // wait for an active job to appear - await waitForSelector('[data-test-id="possibly-active-jobs"]') - // All jobs completed span will appear when all jobs are done. - await waitForSelector( - '[data-test-id="all-jobs-completed"]', - {timeout: defaultJobTimeout} - ) + try { + // wait for an active job to appear + await waitForSelector('[data-test-id="possibly-active-jobs"]') + // All jobs completed span will appear when all jobs are done. + await waitForSelector( + '[data-test-id="all-jobs-completed"]', + {timeout: defaultJobTimeout} + ) + } catch { + console.log("couldn't find active job panel. assuming job completed") + } + await waitForSelector('[data-test-id="clear-completed-jobs-button"]') // Clear retired jobs to remove all jobs completed span. await click('[data-test-id="clear-completed-jobs-button"]') @@ -674,7 +689,7 @@ async function elementClick (elementHandle: any, selector: string) { /** * Waits for a selector to show up and then clicks on it. */ -async function waitForAndClick (selector: string, waitOptions?: any) { +async function waitForAndClick (selector: string, waitOptions?: any, retry?: boolean) { await waitForSelector(selector, waitOptions) await click(selector) } @@ -685,7 +700,7 @@ async function waitForAndClick (selector: string, waitOptions?: any) { */ async function wait (milliseconds: number, reason?: string) { log.info(`waiting ${milliseconds} ms${reason ? ` ${reason}` : ''}...`) - await page.waitFor(milliseconds) + await page.waitForTimeout(milliseconds) } /** @@ -770,6 +785,7 @@ async function getAllElements (selector: string) { */ async function type (selector: string, text: string) { log.info(`typing text: "${text}" into selector: ${selector}`) + await page.focus(selector) await page.type(selector, text) } @@ -795,8 +811,6 @@ async function elementType (elementHandle: any, selector: string, text: string) describe('end-to-end', () => { beforeAll(async () => { - config = (safeLoad(fs.readFileSync('configurations/end-to-end/env.yml')): any) - // Ping the otp endpoint to ensure the server is running. try { log.info(`Pinging OTP at ${OTP_ROOT}`) @@ -813,6 +827,9 @@ describe('end-to-end', () => { log.info('Launching chromium for testing...') browser = await puppeteer.launch(puppeteerOptions) page = await browser.newPage() + cdpSession = await page.target().createCDPSession() + recorder = new PuppeteerScreenRecorder(page) + await recorder.start('/datatools-ui/e2e-test-results/recording.mp4') // setup listeners for various events that happen in the browser. In each of // the following instances, write to the browser events log that will be @@ -821,6 +838,9 @@ describe('end-to-end', () => { // log everything that was logged to the browser console page.on('console', msg => { browserEventLogs.info(msg.text()) }) // log all errors that were logged to the browser console + page.on('warn', warn => { + browserEventLogs.error(warn) + }) page.on('error', error => { browserEventLogs.error(error) browserEventLogs.error(error.stack) @@ -832,12 +852,12 @@ describe('end-to-end', () => { browserEventLogs.error(`Request failed: ${req.method()} ${req.url()}`) }) // log all successful requests - page.on('requestfinished', req => { - browserEventLogs.info(`Request finished: ${req.method()} ${req.url()}`) - }) + // page.on('requestfinished', req => { + // browserEventLogs.info(`Request finished: ${req.method()} ${req.url()}`) + // }) // set the default download behavior to download files to the cwd - page._client.send( + cdpSession.send( 'Page.setDownloadBehavior', { behavior: 'allow', downloadPath: './' } ) @@ -857,6 +877,7 @@ describe('end-to-end', () => { } } // close browser + await recorder.stop() await page.close() await browser.close() log.info('Chromium closed.') @@ -872,19 +893,23 @@ describe('end-to-end', () => { // --------------------------------------------------------------------------- makeTest('should load the page', async () => { - await goto('http://localhost:9966') + await goto('https://datatools-ui-proxy') await waitForSelector('h1') await expectSelectorToContainHtml('h1', 'Data Tools') testResults['should load the page'] = true }) makeTest('should login', async () => { - await goto('http://localhost:9966', { waitUntil: 'networkidle0' }) + const username = process.env.E2E_AUTH0_USERNAME + const password = process.env.E2E_AUTH0_PASSWORD + if (!username || !password) throw Error('E2E username and password must be set!') + + await goto('https://datatools-ui-proxy', { waitUntil: 'networkidle0' }) await waitForAndClick('[data-test-id="header-log-in-button"]') await waitForSelector('button[class="auth0-lock-submit"]', { visible: true }) await waitForSelector('input[class="auth0-lock-input"][name="email"]') - await type('input[class="auth0-lock-input"][name="email"]', config.username) - await type('input[class="auth0-lock-input"][name="password"]', config.password) + await type('input[class="auth0-lock-input"][name="email"]', username) + await type('input[class="auth0-lock-input"][name="password"]', password) await click('button[class="auth0-lock-submit"]') await waitForSelector('#context-dropdown') await wait(2000, 'for projects to load') @@ -895,7 +920,7 @@ describe('end-to-end', () => { const testUserSlug = testUserEmail.split('@')[0] makeTestPostLogin('should allow admin user to create another user', async () => { // navigage to admin page - await goto('http://localhost:9966/admin/users', { waitUntil: 'networkidle0' }) + await goto('https://datatools-ui-proxy/admin/users', { waitUntil: 'networkidle0' }) // click on create user button await waitForAndClick('[data-test-id="create-user-button"]') @@ -967,7 +992,7 @@ describe('end-to-end', () => { describe('project', () => { makeTestPostLogin('should create a project', async () => { - await goto('http://localhost:9966/home', { waitUntil: 'networkidle0' }) + await goto('https://datatools-ui-proxy/home', { waitUntil: 'networkidle0' }) await createProject(testProjectName) // go into the project page and verify that it looks ok-ish @@ -994,7 +1019,7 @@ describe('end-to-end', () => { makeTestPostLogin('should update a project by adding an otp server', async () => { // navigate to server admin page await goto( - `http://localhost:9966/admin/servers`, + `https://datatools-ui-proxy/admin/servers`, { waitUntil: 'networkidle0' } @@ -1014,12 +1039,12 @@ describe('end-to-end', () => { await elementType( newServerPanel, 'input[name="otpServers.$index.publicUrl"]', - 'http://localhost:8080' + 'http://datatools-server:8080' ) await elementType( newServerPanel, 'input[name="otpServers.$index.internalUrl"]', - 'http://localhost:8080/otp' + 'http://datatools-server:8080/otp' ) await elementClick(newServerPanel, '[data-test-id="save-item-button"]') @@ -1034,7 +1059,7 @@ describe('end-to-end', () => { // navigate to home project view await goto( - `http://localhost:9966/home/${testProjectId}`, + `https://datatools-ui-proxy/home/${testProjectId}`, { waitUntil: 'networkidle0' } @@ -1074,7 +1099,7 @@ describe('end-to-end', () => { makeTestPostLogin('should create feed source', async () => { // go to project page await goto( - `http://localhost:9966/project/${testProjectId}`, + `https://datatools-ui-proxy/project/${testProjectId}`, { waitUntil: 'networkidle0' } @@ -1215,17 +1240,18 @@ describe('end-to-end', () => { describe('feed version', () => { makeTestPostFeedSource('should download a feed version', async () => { - await goto(`http://localhost:9966/feed/${feedSourceId}`) + await goto(`https://datatools-ui-proxy/feed/${feedSourceId}`) // Select previous version await waitForAndClick('[data-test-id="decrement-feed-version-button"]') await wait(2000, 'for previous version to be active') // Download version await click('[data-test-id="download-feed-version-button"]') - await wait(5000, 'for file to download') + await wait(15000, 'for file to download') // file should get saved to the current root directory, go looking for it // verify that file exists const downloadsDir = './' + // $FlowFixMe old version of flow doesn't know latest fs methods const files = await fs.readdir(downloadsDir) let feedVersionDownloadFile = '' // assume that this file will be the only one matching the feed source ID @@ -1244,6 +1270,7 @@ describe('end-to-end', () => { expect(await md5File(filePath)).toEqual(await md5File(gtfsUploadFile)) // delete file + // $FlowFixMe old version of flow doesn't know latest fs methods await fs.remove(filePath) }, defaultTestTimeout) @@ -1252,7 +1279,7 @@ describe('end-to-end', () => { // feed versions after this test takes place makeTestPostFeedSource('should delete a feed version', async () => { // browse to feed source page - await goto(`http://localhost:9966/feed/${feedSourceId}`) + await goto(`https://datatools-ui-proxy/feed/${feedSourceId}`) // for whatever reason, waitUntil: networkidle0 was not working with the // above goto, so wait for a few seconds here await wait(5000, 'additional time for page to load') @@ -1334,6 +1361,12 @@ describe('end-to-end', () => { // all of the following editor tests assume the use of the scratch feed describe('feed info', () => { makeEditorEntityTest('should create feed info data', async () => { + // If the editor doesn't load properly, reload the page in hopes of fixing it + try { + await waitForSelector('[data-test-id="editor-feedinfo-nav-button"]:not([disabled])') + } catch { + await page.reload({ waitUntil: 'networkidle0' }) + } // open feed info sidebar await click('[data-test-id="editor-feedinfo-nav-button"]') @@ -1564,7 +1597,9 @@ describe('end-to-end', () => { describe('routes', () => { makeEditorEntityTest('should create route', async () => { // open routes sidebar - await click('[data-test-id="editor-route-nav-button"]') + await waitForAndClick('[data-test-id="editor-route-nav-button"]') + + await wait(1500, 'for route page to open') // wait for route sidebar form to appear and click button to open form // to create route @@ -1573,13 +1608,6 @@ describe('end-to-end', () => { await waitForSelector('[data-test-id="route-route_id-input-container"]') // fill out form - - // set public to yes - await page.select( - '[data-test-id="route-publicly_visible-input-container"] select', - '1' - ) - // set route_id await clearAndType( '[data-test-id="route-route_id-input-container"] input', @@ -2027,7 +2055,7 @@ describe('end-to-end', () => { describe('exceptions', () => { makeEditorEntityTest('should create exception', async () => { // open exception sidebar - await click('[data-test-id="exception-tab-button"]') + await waitForAndClick('[data-test-id="exception-tab-button"]') // wait for exception sidebar form to appear and click button to open // form to create exception @@ -2054,6 +2082,8 @@ describe('end-to-end', () => { await waitForSelector( '[data-test-id="exception-dates-container"] input' ) + await wait(250, 'for date range picker to load') + await wait(1000, 'for date range animation to finish') await clearAndType( '[data-test-id="exception-dates-container"] input', '07/04/18' @@ -2115,6 +2145,7 @@ describe('end-to-end', () => { ) // set new date + await wait(1250, 'for date range picker to load') await clearAndType( '[data-test-id="exception-dates-container"] input', '07/05/18' @@ -2149,6 +2180,68 @@ describe('end-to-end', () => { 'test exception updated to delete' ) }, defaultTestTimeout, 'should create calendar') + + makeEditorEntityTest('should create exception range', async () => { + // create a new exception + await waitForAndClick('[data-test-id="new-scheduleexception-button"]') + + // name + await type( + '[data-test-id="exception-name-input-container"] input', + 'test exception range' + ) + + // exception type + await page.select( + '[data-test-id="exception-type-input-container"] select', + '7' // no service + ) + + // add start range exception date + await click('[data-test-id="exception-add-date-button"]') + await waitForSelector( + '[data-test-id="exception-dates-container"] input' + ) + await wait(1050, 'for date range picker to load') + await clearAndType( + '[data-test-id="exception-dates-container"] input', + '08/04/18' + ) + + await wait(1050, 'for date range picker to load') + await click('[data-test-id="exception-add-range"]') + + // add end of range exception date (July 10, 2018) + await wait(1050, 'for date range picker to load') + await waitForSelector( + '[data-test-id="exception-date-range-0-2"] input' + ) + + await wait(1050, 'for date range picker to load') + await clearAndType( + '[data-test-id="exception-date-range-0-2"] input', + '08/10/18' + ) + + // save + await click('[data-test-id="save-entity-button"]') + await wait(2000, 'for save to happen') + + // reload to make sure stuff was saved + await page.reload({ waitUntil: 'networkidle0' }) + + // wait for exception sidebar form to appear + await waitForSelector( + '[data-test-id="exception-name-input-container"]' + ) + + // verify data was saved and retrieved from server + // TODO: verify the contents of the range? + await expectSelectorToContainHtml( + '[data-test-id="exception-name-input-container"]', + 'test exception range' + ) + }, defaultTestTimeout, 'should create calendar') }) // --------------------------------------------------------------------------- @@ -2341,7 +2434,8 @@ describe('end-to-end', () => { 'should create pattern', async () => { // open route sidebar - await click('[data-test-id="editor-route-nav-button"]') + await waitForAndClick('[data-test-id="editor-route-nav-button"]') + await wait(2000, 'for page to catch up with itself') // wait for route sidebar form to appear and select first route await waitForAndClick('.entity-list-row') @@ -2357,7 +2451,7 @@ describe('end-to-end', () => { await wait(2000, 'for page to catch up with itself') // click add stop by name - await click('[data-test-id="add-stop-by-name-button"]') + await waitForAndClick('[data-test-id="add-stop-by-name-button"]') // wait for stop selector to show up await waitForSelector('.pattern-stop-card .Select-control') @@ -2374,6 +2468,10 @@ describe('end-to-end', () => { await click('[data-test-id="add-pattern-stop-button"]') await wait(2000, 'for auto-save to happen') + // save + await click('[data-test-id="save-entity-button"]') + await wait(2000, 'for save to happen') + // reload to make sure stuff was saved await page.reload({ waitUntil: 'networkidle0' }) @@ -2382,6 +2480,7 @@ describe('end-to-end', () => { '[data-test-id="pattern-title-New Pattern"]' ) + await wait(2000, 'for trip pattern list to load') // verify data was saved and retrieved from server await expectSelectorToContainHtml( '.trip-pattern-list', @@ -2507,22 +2606,22 @@ describe('end-to-end', () => { await page.keyboard.press('Enter') // Laurel Dr arrival - await page.keyboard.type('1234') + await page.keyboard.type('12:34') await page.keyboard.press('Tab') await page.keyboard.press('Enter') // Laurel Dr departure - await page.keyboard.type('1235') + await page.keyboard.type('12:35') await page.keyboard.press('Tab') await page.keyboard.press('Enter') // Russell Av arrival - await page.keyboard.type('1244') + await page.keyboard.type('12:44') await page.keyboard.press('Tab') await page.keyboard.press('Enter') // Russell Av departure - await page.keyboard.type('1245') + await page.keyboard.type('12:45') await page.keyboard.press('Enter') // save @@ -2599,11 +2698,11 @@ describe('end-to-end', () => { await page.keyboard.press('Enter') await page.keyboard.type('test-trip-to-delete') await page.keyboard.press('Enter') - await wait(2000, 'for save to happen') + await wait(4000, 'for save to happen') // save await click('[data-test-id="save-trip-button"]') - await wait(2000, 'for save to happen') + await wait(6000, 'for save to happen') // reload to make sure stuff was saved await page.reload({ waitUntil: 'networkidle0' }) @@ -2627,7 +2726,8 @@ describe('end-to-end', () => { // confirm delete await waitForAndClick('[data-test-id="modal-confirm-ok-button"]') - await wait(2000, 'for delete to happen') + await wait(3000, 'for delete to happen') + await page.reload({ waitUntil: 'networkidle0' }) // verify that trip to delete is no longer listed await expectSelectorToNotContainHtml( @@ -2668,7 +2768,7 @@ describe('end-to-end', () => { makeEditorEntityTest('should make snapshot active version', async () => { // go back to feed // not sure why, but clicking on the nav home button doesn't work - await goto(`http://localhost:9966/feed/${scratchFeedSourceId}`) + await goto(`https://datatools-ui-proxy/feed/${scratchFeedSourceId}`) // wait for page to be visible and go to snapshots tab await waitForAndClick('#feed-source-viewer-tabs-tab-snapshots') @@ -2676,6 +2776,10 @@ describe('end-to-end', () => { // wait for snapshots tab to load and publish snapshot await waitForAndClick('[data-test-id="publish-snapshot-button"]') + + // wait for snapshot export modal and click "no" to proprietary file export + await waitForAndClick('[data-test-id="export-patterns-modal-no"]') + // wait for version to get created await waitAndClearCompletedJobs() @@ -2709,6 +2813,7 @@ describe('end-to-end', () => { await waitForAndClick('[data-test-id="deploy-server-0-button"]') // wait for deployment dialog to appear await waitForSelector('[data-test-id="confirm-deploy-server-button"]') + await wait(1500, 'for deployment panel to properly load') // get the router name const innerHTML = await getInnerHTMLFromSelector( @@ -2725,7 +2830,7 @@ describe('end-to-end', () => { // wait for jobs to complete await waitAndClearCompletedJobs() - }, defaultTestTimeout + 30000) // Add thirty seconds for deployment job + }, defaultTestTimeout + 60000) // Add sixty 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') diff --git a/__tests__/test-utils/mock-data/manager.js b/__tests__/test-utils/mock-data/manager.js index 5db2d031a..ac4fb3129 100644 --- a/__tests__/test-utils/mock-data/manager.js +++ b/__tests__/test-utils/mock-data/manager.js @@ -13,6 +13,20 @@ import type { let COUNTER = 0 +/** + * Make a mock deployment summary given a project and some FeedVersions. This is a + * function so that circular references can be defined. + */ +export function makeMockDeploymentSummary () { + return { + dateCreated: 1553292345720, + deployedTo: null, + // Don't increment counter as we want to match the main deployment + id: `mock-deployment-id-${COUNTER}`, + lastDeployed: null, + name: 'mock-deployment' + } +} /** * Make a mock deployment given a project and some FeedVersions. This is a * function so that circular references can be defined. @@ -57,6 +71,7 @@ export function makeMockDeployment ( peliasCsvFiles: [], peliasResetDb: null, peliasUpdate: null, + peliasSynonymsBase64: null, pinnedfeedVersionIds: [], projectBounds: {east: 0, west: 0, north: 0, south: 0}, projectId: project.id, @@ -95,13 +110,14 @@ export const mockProject = { pinnedDeploymentId: null, peliasWebhookUrl: null, routerConfig: { - carDropoffTime: null, - numItineraries: null, + driveDistanceReluctance: null, + itineraryFilters: {nonTransitGeneralizedCostLimit: null}, requestLogFile: null, stairsReluctance: null, updaters: null, walkSpeed: null }, + sharedStopsConfig: null, useCustomOsmBounds: false, user: null } @@ -114,7 +130,6 @@ export const mockFeedWithVersion = { externalProperties: {}, id: 'mock-feed-with-version-id', isPublic: false, - lastFetched: 1543389038810, lastUpdated: 1543389038810, latestValidation: { agencies: null, @@ -153,6 +168,38 @@ export const mockFeedWithVersion = { versionCount: 1 } +// a mock feed source summary +export const mockFeedSourceSummaryWithVersion = { + deployable: false, + id: 'mock-feed-with-version-id', + isPublic: false, + lastUpdated: 1543389038810, + latestValidation: { + agencies: null, + agencyCount: 1, + avgDailyRevenueTime: 0, + bounds: { + north: 39.0486949672717, + south: 38.92884, + east: -76.481211, + west: -76.5673055566884 + }, + endDate: '20190801', + errorCount: 78, + feedVersionId: 'mock-feed-version-id', + loadFailureReason: null, + loadStatus: 'SUCCESS', + routeCount: 10, + startDate: '20180801', + stopCount: 237, + stopTimesCount: 11170, + tripCount: 415 + }, + labelIds: [], + name: 'test feed with a version', + projectId: mockProject.id +} + // a mock feed with no versions export const mockFeedWithoutVersion = { dateCreated: 1544831411569, @@ -161,7 +208,6 @@ export const mockFeedWithoutVersion = { externalProperties: {}, id: 'mock-feed-without-version-id', isPublic: false, - lastFetched: null, name: 'test feed with no version', labelIds: [], noteCount: 0, @@ -326,6 +372,7 @@ export const mockFeedVersion = { feedVersionId: 'mock-feed-version-id', loadFailureReason: null, loadStatus: 'SUCCESS', + mobilityDataResult: {}, routeCount: 10, startDate: '20180801', stopCount: 237, @@ -353,6 +400,7 @@ export const mockDeployment = makeMockDeployment( mockProjectWithDeployment, [mockFeedVersion] ) +export const mockDeploymentSummary = makeMockDeploymentSummary() mockProjectWithDeployment.deployments.push(mockDeployment) mockProjectWithDeployment.feedSources.push(mockFeedWithVersion) @@ -370,7 +418,6 @@ function makeUser (profile) { profile, permissions: new UserPermissions(profile.app_metadata.datatools), recentActivity: null, - redirectOnSuccess: null, subscriptions: new UserSubscriptions(profile.app_metadata.datatools) } } diff --git a/__tests__/test-utils/mock-data/store.js b/__tests__/test-utils/mock-data/store.js index a99f04611..e59464c12 100644 --- a/__tests__/test-utils/mock-data/store.js +++ b/__tests__/test-utils/mock-data/store.js @@ -4,7 +4,7 @@ import multi from '@conveyal/woonerf/store/multi' import promise from '@conveyal/woonerf/store/promise' import {middleware as fetchMiddleware} from '@conveyal/woonerf/fetch' import Enzyme, {mount} from 'enzyme' -import EnzymeReactAdapter from 'enzyme-adapter-react-15.4' +import EnzymeReactAdapter from '@wojtekmaj/enzyme-adapter-react-17' import {mountToJson} from 'enzyme-to-json' import clone from 'lodash/cloneDeep' import {get} from 'object-path' @@ -43,10 +43,10 @@ import {defaultState as defaultManagerProjectsState} from '../../../lib/manager/ import {defaultState as defaultManagerStatusState} from '../../../lib/manager/reducers/status' import {defaultState as defaultManagerUiState} from '../../../lib/manager/reducers/ui' import {defaultState as defaultManagerUserState} from '../../../lib/manager/reducers/user' -import * as manager from './manager' - import type {AppState} from '../../../lib/types/reducers' +import * as manager from './manager' + Enzyme.configure({ adapter: new EnzymeReactAdapter() }) const defaultManagerState = { diff --git a/__tests__/test-utils/setup-e2e.js b/__tests__/test-utils/setup-e2e.js index 995892a19..353ace053 100644 --- a/__tests__/test-utils/setup-e2e.js +++ b/__tests__/test-utils/setup-e2e.js @@ -12,6 +12,7 @@ const { downloadFile, getTestFolderFilename, isCi, + isDocker, isUiRepo, loadYamlFile, requireEnvVars, @@ -25,16 +26,18 @@ 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', + 'MONGO_HOST', + 'MONGO_DB_NAME', 'OSM_VEX', 'SPARKPOST_KEY', 'SPARKPOST_EMAIL' ] + /** * download, configure and start an instance of datatools-server */ @@ -130,7 +133,7 @@ async function startBackendServer () { } = process.env const serverEnv = results.readServerTemplate - serverEnv.application.client_assets_url = 'http://localhost:4000' + serverEnv.application.client_assets_url = 'http://datatools-server:4000' serverEnv.application.data.gtfs_s3_bucket = S3_BUCKET serverEnv.application.data.use_s3_storage = true serverEnv.extensions.transitfeeds.key = TRANSITFEEDS_KEY @@ -385,24 +388,17 @@ function recreateEndToEndTestResultDirectory () { async function verifySetupForLocalEnvironment () { const errors = [] - // make sure e2e.yml exists - try { - await fs.stat('configurations/end-to-end/env.yml') - } catch (e) { - errors.push(new Error('Failed to detect file `configurations/end-to-end/env.yml`')) - } - // make sure services are running const endpointChecks = [ { name: 'Front-end server', - url: 'http://localhost:9966' + url: 'http://datatools-ui:9966' }, { name: 'Back-end server', - url: 'http://localhost:4000' + url: 'http://datatools-server:4000' }, { name: 'OTP server', - url: 'http://localhost:8080' + url: 'http://datatools-server:8080' } ] @@ -415,7 +411,7 @@ async function verifySetupForLocalEnvironment () { // Download OTP jar into /opt/otp/ if not already present. const otpJarExists = await fs.exists(otpJarForOtpRunner) - if (!otpJarExists) { + if (!otpJarExists && !isDocker) { await downloadFile(otpJarMavenUrl, otpJarForOtpRunner) } @@ -499,7 +495,7 @@ module.exports = async function () { // do different setup depending on runtime environment const setupItems = [] - if (isCi) { + if (isCi && !isDocker) { setupItems.push(setupForContinuousIntegrationEnvironment()) } else { setupItems.push(verifySetupForLocalEnvironment()) diff --git a/__tests__/test-utils/teardown-e2e.js b/__tests__/test-utils/teardown-e2e.js index 232de18b7..aefbd5c29 100644 --- a/__tests__/test-utils/teardown-e2e.js +++ b/__tests__/test-utils/teardown-e2e.js @@ -22,7 +22,7 @@ const logsZipfile = 'logs.zip' const repo = process.env.GITHUB_WORKSPACE ? process.env.GITHUB_WORKSPACE.split(path.sep).pop() : '' -const buildNum = process.env.GITHUB_RUN_ID +const buildNum = process.env.GITHUB_RUN_ID || 'localhost' const uploadedLogsFilename = `${repo}-build-${buildNum}-e2e-logs.zip` const {LOGS_S3_BUCKET} = process.env @@ -155,9 +155,14 @@ async function uploadToMicrosoftTeams () { console.log('posting message to MS Teams') - const testResults = require( - path.resolve(`./${getTestFolderFilename('results.json')}`) - ) + let testResults = {success: false, numPassedTests: 0, numTotalTests: 0} + try { + testResults = require( + path.resolve('/datatools-ui/e2e-test-results/results.json') + ) + } catch { + console.warn("Couldn't read results.json!") + } const actions = [{ '@type': 'OpenUri', name: `View GitHub Action Build #${buildNum}`, @@ -183,7 +188,7 @@ async function uploadToMicrosoftTeams () { } let fetchResponse - const commit = process.env.GITHUB_SHA + const commit = process.env.GITHUB_SHA || 'localhost' const baseRepoUrl = `https://github.com/ibi-group/datatools-${isUiRepo ? 'ui' : 'server'}` const commitUrl = `${baseRepoUrl}/commit/${commit}` try { @@ -196,7 +201,7 @@ async function uploadToMicrosoftTeams () { '@type': 'MessageCard', themeColor: '0072C6', title: `${repo} e2e test ${testResults.success ? 'passed. ✅' : 'failed. ❌'}`, - text: `📁 **branch:** ${process.env.GITHUB_REF_SLUG}\n + text: `📁 **branch:** ${process.env.GITHUB_REF_SLUG || 'branch not detected'}\n 📄 **commit:** [${commit.slice(0, 6)}](${commitUrl})\n 📊 **result:** ${testResults.numPassedTests} / ${testResults.numTotalTests} tests passed\n `, @@ -247,10 +252,10 @@ async function uploadToSlack () { * slack or MS Teams channel (if defined) */ function uploadLogs () { - if (!(slackConfigured || msTeamsConfigured)) { - console.warn('Log upload environment variables undefined, not uploading logs anywhere!') - return - } + // if (!(slackConfigured || msTeamsConfigured)) { + // console.warn('Log upload environment variables undefined, not uploading logs anywhere!') + // return + // } const output = fs.createWriteStream(logsZipfile) const archive = archiver('zip') @@ -273,6 +278,7 @@ function uploadLogs () { }) .catch(err => { if (err) { + console.log(err) return makeUploadFailureHandler( 'An error occurred while uploading the logs' )(err) diff --git a/__tests__/test-utils/utils.js b/__tests__/test-utils/utils.js index e7ca9774f..40d928e6d 100644 --- a/__tests__/test-utils/utils.js +++ b/__tests__/test-utils/utils.js @@ -8,8 +8,9 @@ const request = require('request') const collectingCoverage = process.env.COLLECT_COVERAGE const isCi = !!process.env.CI +const isDocker = !!process.env.IS_DOCKER const isUiRepo = process.env.GITHUB_REPOSITORY === 'ibi-group/datatools-ui' -const testFolderPath = 'e2e-test-results' +const testFolderPath = process.env.TEST_FOLDER_PATH || 'e2e-test-results' /** * Download a file using a stream @@ -148,6 +149,7 @@ module.exports = { downloadFile, getTestFolderFilename, isCi, + isDocker, isUiRepo, killDetachedProcess, loadYamlFile, diff --git a/configurations/default/env.yml.tmp b/configurations/default/env.yml.tmp index d05c954af..03d0c7c68 100644 --- a/configurations/default/env.yml.tmp +++ b/configurations/default/env.yml.tmp @@ -1,13 +1,27 @@ AUTH0_CLIENT_ID: your-auth0-client-id +AUTH0_CONNECTION_NAME: your-auth0-connection-name AUTH0_DOMAIN: your-auth0-domain BUGSNAG_KEY: optional-bugsnag-key MAPBOX_ACCESS_TOKEN: your-mapbox-access-token MAPBOX_MAP_ID: mapbox/outdoors-v11 MAPBOX_ATTRIBUTION: © Mapbox © OpenStreetMap Improve this map +MAP_BASE_URL: optional-map-tile-url +# MAP_BASE_URL: http://tile.openstreetmap.org/{z}/{x}/{y}.png # Uncomment it if maps are gray 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 +# Optional overrides to use custom service or different api key for certain bounding boxes. +# (uncomment below to enable) +# GRAPH_HOPPER_ALTERNATES: +# - URL: http://localhost:8989/ +# KEY: your-localhost-graph-hopper-key +# BBOX: +# - -170 +# - 6 +# - -46 +# - 83 # GRAPH_HOPPER_POINT_LIMIT: 10 # Defaults to 30 +GOOGLE_ANALYTICS_TRACKING_ID: optional-ga-key +DISABLE_AUTH: true diff --git a/configurations/mtc-docs/.readthedocs.yaml b/configurations/mtc-docs/.readthedocs.yaml new file mode 100644 index 000000000..3b057f68f --- /dev/null +++ b/configurations/mtc-docs/.readthedocs.yaml @@ -0,0 +1,20 @@ +# Read the Docs configuration file for MkDocs projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.13" + +# Install mkdocs macros and other plugins +# Per readthedocs: paths are relative to the root of the project. +python: + install: + - requirements: docs/requirements.txt + +mkdocs: + configuration: configurations/mtc-docs/mkdocs-mtc.yml diff --git a/configurations/mtc-docs/mkdocs-mtc.yml b/configurations/mtc-docs/mkdocs-mtc.yml new file mode 100644 index 000000000..df89de75f --- /dev/null +++ b/configurations/mtc-docs/mkdocs-mtc.yml @@ -0,0 +1,29 @@ +INHERIT: ../../mkdocs.yml +site_name: Transit Data Manager Docs +docs_dir: ../../docs + +extra: + product_name: MTC Transit Data Manager (TDM) + support_email: transitdata@511.org + +nav: +- Home: 'index.md' +- Data Manager: + - 'Introduction': 'user/introduction.md' + - 'Managing Projects & Feeds': 'user/managing-projects-feeds.md' + - 'Publishing Feeds': 'user/publishing-feeds.md' + - 'Merging Feeds': 'user/merging-feeds.md' + - 'Feed Version Summary': 'user/feed-version-summary.md' + - 'Managing Users': 'user/managing-users.md' + - 'Service Alerts Manager': 'user/service-alerts.md' + - 'Searching for Routes and Stops': 'user/searching-routes-stops.md' + - 'GTFS+ Editor': 'user/gtfs-plus-editor.md' +- 'GTFS Editor': + - Getting Started: 'user/editor/getting-started.md' + - Stops: 'user/editor/stops.md' + - Routes: 'user/editor/routes.md' + - Patterns: 'user/editor/patterns.md' + - Schedules: 'user/editor/schedules.md' + - Fares: 'user/editor/fares.md' +- Appendices: + - GTFS Validation Warnings: 'user/appendix-gtfs-warnings.md' diff --git a/configurations/test/env.yml b/configurations/test/env.yml index 373ab9582..1b41ff506 100644 --- a/configurations/test/env.yml +++ b/configurations/test/env.yml @@ -1,4 +1,5 @@ AUTH0_CLIENT_ID: mock-client-id +AUTH0_CONNECTION_NAME: auth0-connection-name AUTH0_DOMAIN: test.domain.com GRAPH_HOPPER_KEY: test MAPBOX_ACCESS_TOKEN: test diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..9055475a6 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,71 @@ +version: "3.8" + +x-common-variables: &common-variables + - BUGSNAG_KEY=${BUGSNAG_KEY} + - S3_BUCKET=${S3_BUCKET} + - LOGS_S3_BUCKET=${LOGS_S3_BUCKET} + - MS_TEAMS_WEBHOOK_URL=${MS_TEAMS_WEBHOOK_URL} + - MAPBOX_ACCESS_TOKEN=${MAPBOX_ACCESS_TOKEN} + - GITHUB_SHA=${GITHUB_SHA} + - GITHUB_REF_SLUG=${GITHUB_REF_SLUG} + - TRANSITFEEDS_KEY=${TRANSITFEEDS_KEY} + - GITHUB_REPOSITORY=${GITHUB_REPOSITORY} + - GITHUB_WORKSPACE=${GITHUB_WORKSPACE} + - GITHUB_RUN_ID=${GITHUB_RUN_ID} + - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} + - AUTH0_PUBLIC_KEY=${AUTH0_PUBLIC_KEY} + - AUTH0_CONNECTION_NAME=${AUTH0_CONNECTION_NAME} + - AUTH0_DOMAIN=${AUTH0_DOMAIN} + - AUTH0_API_CLIENT=${AUTH0_API_CLIENT} + - AUTH0_API_SECRET=${AUTH0_API_SECRET} + - OSM_VEX=${OSM_VEX} + - SPARKPOST_KEY=${SPARKPOST_KEY} + - SPARKPOST_EMAIL=${SPARKPOST_EMAIL} + - GTFS_DATABASE_URL=jdbc:postgresql://postgres/dmtest + - GTFS_DATABASE_USER=root + - GTFS_DATABASE_PASSWORD=e2e + - MONGO_DB_NAME=data_manager + - MONGO_HOST=mongo:27017 + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_REGION=${AWS_REGION} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - DISABLE_AUTH=true + +services: + datatools-server: + image: ghcr.io/ibi-group/datatools-server:dev + restart: always + environment: *common-variables + volumes: + - type: bind + source: ./server/ + target: /config + ports: + - "4000:4000" + datatools-ui: + build: + context: ../ + dockerfile: ./docker/ui/Dockerfile + args: *common-variables + restart: always + environment: *common-variables + ports: + - "9966:9966" + mongo: + image: mongo + restart: always + volumes: + - dt-mongo:/data/db + postgres: + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_USER: root + POSTGRES_PASS: e2e + POSTGRES_DB: dmtest + image: postgres + restart: always + volumes: + - dt-postgres:/var/lib/postgresql/data +volumes: + dt-postgres: + dt-mongo: diff --git a/docker/server/env.yml b/docker/server/env.yml new file mode 100644 index 000000000..9fe4c3cbb --- /dev/null +++ b/docker/server/env.yml @@ -0,0 +1,5 @@ +DISABLE_AUTH: TRUE +GTFS_DATABASE_URL: jdbc:postgresql://postgres/dmtest +MONGO_DB_NAME: data_manager +MONGO_HOST: mongo +AUTH0_CLIENT_ID: disable_auth \ No newline at end of file diff --git a/docker/server/server.yml b/docker/server/server.yml new file mode 100644 index 000000000..f212cbe7e --- /dev/null +++ b/docker/server/server.yml @@ -0,0 +1,67 @@ +application: + title: Data Tools + logo: https://d2tyb7byn1fef9.cloudfront.net/ibi_group-128x128.png + logo_large: https://d2tyb7byn1fef9.cloudfront.net/ibi_group_black-512x512.png + client_assets_url: https://example.com + shortcut_icon_url: https://d2tyb7byn1fef9.cloudfront.net/ibi-logo-original%402x.png + public_url: http://localhost:9966 + notifications_enabled: false + docs_url: http://conveyal-data-tools.readthedocs.org + support_email: support@ibigroup.com + port: 4000 + data: + gtfs: /tmp + use_s3_storage: false + s3_region: us-east-1 + gtfs_s3_bucket: bucket-name +modules: + enterprise: + enabled: false + editor: + enabled: true + deployment: + enabled: true + ec2: + enabled: false + default_ami: ami-your-ami-id + tag_key: a-tag-key-to-add-to-all-instances + tag_value: a-tag-value-to-add-to-all-instances + # Note: using a cloudfront URL for these download URLs will greatly + # increase download/deploy speed. + otp_download_url: https://optional-otp-repo.com + user_admin: + enabled: false + gtfsapi: + enabled: true + load_on_fetch: false + # use_extension: mtc + # update_frequency: 30 # in seconds + manager: + normalizeFieldTransformation: + # Enter capitalization exceptions (e.g. acronyms), in the desired case, and separated by commas. + defaultCapitalizationExceptions: + - ACE + - BART + # Enter substitutions (e.g. substitute '@' with 'at'), one dashed entry for each substitution, with: + # - pattern: the regex string pattern that will be replaced, + # - replacement: the replacement string for that pattern, + # - normalizeSpace: if true, the resulting field value will include one space before and after the replacement string. + # Note: if the replacement must be blank, then normalizeSpace should be set to false + # and whitespace management should be handled in pattern instead. + # Substitutions are executed in order they appear in the list. + defaultSubstitutions: + - description: "Replace '@' with 'at', and normalize space." + pattern: "@" + replacement: at + normalizeSpace: true + - description: "Replace '+' (\\+ in regex) and '&' with 'and', and normalize space." + pattern: "[\\+&]" + replacement: and + normalizeSpace: true +extensions: + transitland: + enabled: true + api: https://transit.land/api/v1/feeds + transitfeeds: + enabled: true + api: http://api.transitfeeds.com/v1/getFeeds \ No newline at end of file diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile new file mode 100644 index 000000000..a3f1bc811 --- /dev/null +++ b/docker/ui/Dockerfile @@ -0,0 +1,16 @@ +FROM node:14 +WORKDIR /datatools-build + +ARG BUGSNAG_KEY + +RUN cd /datatools-build +COPY package.json yarn.lock patches /datatools-build/ +RUN yarn +COPY . /datatools-build/ +COPY configurations/default /datatools-config/ + + +# Copy the tmp file to the env.yml if no env.yml is present +RUN cp -R -u -p /datatools-config/env.yml.tmp /datatools-config/env.yml + +CMD yarn run mastarm build --env dev --serve --proxy http://datatools-server:4000/api # \ No newline at end of file diff --git a/docs/dev/api_interaction.md b/docs/dev/api_interaction.md index 75537569b..2916742c7 100644 --- a/docs/dev/api_interaction.md +++ b/docs/dev/api_interaction.md @@ -1,4 +1,4 @@ -# API Interaction Transcript +# API Interaction The following is a set of instructions on API calls needed to upload and validate a feed, wait for the tasks' completion, and then browse its contents. All of the endpoints needed to load and process a GTFS file are REST-based. The endpoints diff --git a/docs/dev/deployment.md b/docs/dev/deployment.md index cde4c3ffb..7bbda2f57 100644 --- a/docs/dev/deployment.md +++ b/docs/dev/deployment.md @@ -110,7 +110,7 @@ Auth0 is used for authentication in the application. If you don't need authentic - Application level - Allowed Callback URLs - Allowed Origins (CORS) - - keep all other default settings + - Keep all other default settings #### Creating your first user Create your first Auth0 user through Auth0 web console (Users > Create User). In @@ -141,9 +141,9 @@ AUTH0_DOMAIN: your-auth0-domain.auth.com AUTH0_CLIENT_ID: your-auth0-client-id ``` -Update the following properties in `datatools-server` `env.yml` to reflect the secure Auth0 application settings. +Update the following properties in `datatools-server` and `env.yml` to reflect the secure Auth0 application settings. -**Note:** for older Auth0 accounts/tenants, it is possible to use the Auth0 secret token, which uses the HS256 algorithm, but newer Auth0 tenants will need to specify the absolute path of their `.pem` file in the `AUTH0_PUBLIC_KEY` property. This public key only needs to be downloaded one time for your Auth0 tenant at `https://[your_domain].auth0.com/pem`. +**Note:** For older Auth0 accounts or tenants, it is possible to use the Auth0 secret token with the HS256 algorithm is possible. However, newer Auth0 tenants will need to specify the absolute path of their `.pem` file in the `AUTH0_PUBLIC_KEY` property. This public key only needs to be downloaded one time for your Auth0 tenant at `https://[your_domain].auth0.com/pem`. ```yaml AUTH0_SECRET: your-auth0-client-secret # used for pre-September 2017 Auth0 accounts @@ -151,34 +151,39 @@ AUTH0_PUBLIC_KEY: /location/of/auth0-account.pem # used for post-September 2017 AUTH0_TOKEN: your-auth0-api-token ``` -**Note**: to generate the `api_token`, go to Documentation > Management API. After adding scopes, your token will appear in the input field. +**Note**: To generate the `api_token`, go to Documentation > Management API. After adding scopes, your token will appear in the input field. -![Auth0 token generator](../img/auth0-token-generator.png) + To allow for the creation, deletion and editing of users you must generate a token for the following scopes: - **users**: - read, update, create and delete - **users_app_metadata**: - - read, update, create and delete` + - read, update, create and delete -#### Auth0 Rule Configuration: making app_metadata and user_metadata visible via token (only required for "new" Auth0 accounts/tenants) -If using OIDC-conformant clients/APIs (which appears to be mandatory for new Auth0 tenants), you must set up a custom Auth0 rule to add app_metadata and user_metadata to the user's token (Note: this is not the default for older, "legacy" Auth0 accounts). Go to Rules > Create Rule > empty rule and add the following code snippet. If you'd like the rule to only apply to certain clients, you can keep the conditional block that checks for `context.clientID` value. Otherwise, this conditional block is unnecessary. +#### Auth0 Post-Login Action Configuration: making `app_metadata` and `user_metadata` visible via token -``` -function (user, context, callback) { - if (context.clientID === 'YOUR_CLIENT_ID') { - var namespace = 'http://datatools/'; - if (context.idToken && user.user_metadata) { - context.idToken[namespace + 'user_metadata'] = user.user_metadata; - } - if (context.idToken && user.app_metadata) { - context.idToken[namespace + 'app_metadata'] = user.app_metadata; - } +If using OIDC-conformant clients/APIs (which appears to be mandatory for new Auth0 tenants), you must set up a custom Auth0 action to add `app_metadata` and `user_metadata` to the user's id token (Note: this is not the default for older, "legacy" Auth0 accounts). + +To set up the action, go to Actions > Flows > Login, then under Add action > Custom, click `Create Action`. Fill in the action name and pick a recommended runtime, and click `Create`. Modify the function `onExecutePostLogin` as follows, then click `Save Draft`: + +```js +exports.onExecutePostLogin = async (event, api) => { + if (event.authorization) { + const namespace = 'http://datatools'; + api.idToken.setCustomClaim(`${namespace}/user_metadata`, event.user.user_metadata); + api.idToken.setCustomClaim(`${namespace}/app_metadata`, event.user.app_metadata); } - callback(null, user, context); -} +}; ``` +If you want the rule to apply only to specific clients, you can retain the conditional block that checks the `context.clientID` value. Otherwise, you can remove this conditional block if it's not needed. +This rule will ensure that `app_metadata` and `user_metadata` are included in the user's token, as required for OIDC-conformant clients/APIs in new Auth0 tenants. + +You can test the action with mock token data using the Test tab. Once ready, click `Deploy`, then click `Back to Flow`. +In the diagram, drag the action between the Start and Complete steps, then click `Apply`. +You can test that the action is correctly executed by logging-in to datatools with an admin user +and checking that the Admin functionality is available. ## Building and Running the Application @@ -239,6 +244,8 @@ Enables the GTFS Editor module. - `MAPBOX_ACCESS_TOKEN` - `R5_URL` (optional parameter for r5 routing in editor pattern drawing) +**Note:** If maps are gray, add the property `MAP_BASE_URL: http://tile.openstreetmap.org/{z}/{x}/{y}.png` into `env.yml`. + ### R5 network validation While the application handles basic validation even without the `r5_network` @@ -304,6 +311,7 @@ extensions: ``` ### Integration with [TransitFeeds](http://transitfeeds.com/) +**Note**: TransitFeeds is not regularly updated and is being replaced by the [MobilityData Database](https://database.mobilitydata.org/) Ensure that the `extensions:transitfeeds:enabled` flag is set to `true` in `config.yml`, and provide your API key: diff --git a/docs/dev/development.md b/docs/dev/development.md index 21988c174..8e8ae5221 100644 --- a/docs/dev/development.md +++ b/docs/dev/development.md @@ -1,5 +1,5 @@ # Development -These instructions should allow you to get Data Tools / Editor / Catalogue up and running within an integrated development environment, allowing you to work on the code and debug it. We all use IntelliJ so instructions will currently be only for that environment. +These instructions should allow you to get TRANSIT-data-tools / Editor / Catalogue up and running within an integrated development environment, allowing you to work on the code and debug it. We all use IntelliJ so instructions will currently be only for that environment. ## Components The system is made up of two different projects: @@ -44,3 +44,9 @@ To specify your own configuration that overrides the defaults: ``` yarn start -- --config /path/to/config ``` + +## E2E tests + +The e2e tests have been Dockerized, which allows them to be run easily anywhere `docker compose` works. To run them on localhost, first create a `.env` file in the `__tests__/e2e`. `docker compose` will alert you as to which variables must be present. + +To run the tests, run `docker compose -f docker-compose.yml up --abort-on-container-exit` in the `__tests__/e2e/` directory. diff --git a/docs/dev/localization.md b/docs/dev/localization.md new file mode 100644 index 000000000..49f385314 --- /dev/null +++ b/docs/dev/localization.md @@ -0,0 +1,34 @@ +# Localization + +## Adding translations for a new language +To add support for a new language, you need to perform the following steps: + +1. Create a new language file in folder `i18n`, e.g. by copying the `english.yml` file. +2. In the newly created `.yml`, adapt the first two lines: `_id` should conform to the ISO 639 language code, `_name` to the localized language name. +3. In `lib/common/util/config.js`, add the import of the new language file at the mark `// Add additional language files here.` Mind to add an `// $FlowFixMe` hint before the new line to make the linter happy +4. Translate all messages in the `.yml` file. Note, that names surrounded by percent characters (`%`) denote parameters and must not be translated. +5. Add a new line to the CHANGELOG, e.g. `Add support for `. +5. Before commiting, run `yarn run lint-messages` or `yarn run test`. + + +## Internationalizing components +To internationalize components not yet translated, perform the following steps: + +* For components not yet translated: + * import getComponentMessages from common/util.config. + * assign getComponentMessages('') to a component properties `messages` + * extract not yet translated messages from component files to `i18n/english.yml` and replace their original text by this.messages(''). + * in case the original message contains dynamic parts, you should create placeholders (e.g. `%placeholder%`, and replacing that string with the intended value after calling `this.messages('')`). Note that numeric parameters should be converted to strings. +* When done, add the new message keys to the existing translation files, e.g. using [yq](https://mikefarah.gitbook.io/yq/v/v2.x/), like e.g. `yq merge i18n/german.yml i18n/english.yml | sponge i18n/german.yml`. Note: Besides merging the missing keys, yq has some reformatting/reordering side effects, i.e. messages surrounded by brackets need to be quoted, otherwise they are converted to nested arrays. +* Periodically, and especially before commiting, run `yarn run lint-messages` or `yarn run test` to be sure all tests are still running. + +Note: console log messages are not intended to be localized + +### Open issues +There are a few sections, which are not yet prepared for i18n and will not adapt to the user's locale: +* The alerts module (`lib/alerts/**`) is not yet fully internationalized. +* A couple of messages (i.e. job names and status) are generated server-side and are not internationalized yet. +* Time and date formatting performed by the moment javascript lib are not locale aware. +* Some messages from the react-bootstrap table component, e.g. `Showing row x-y of z`. +* GTFS-related field names and descriptions defined in GTFS.yml. + diff --git a/docs/img/add-fare-zone.png b/docs/img/add-fare-zone.png deleted file mode 100644 index c1ea0fec8..000000000 Binary files a/docs/img/add-fare-zone.png and /dev/null differ diff --git a/docs/img/auth0-token-generator.png b/docs/img/auth0-token-generator.png deleted file mode 100644 index 0acae707e..000000000 Binary files a/docs/img/auth0-token-generator.png and /dev/null differ diff --git a/docs/img/configure-feed-transformations.png b/docs/img/configure-feed-transformations.png deleted file mode 100644 index fb4244d90..000000000 Binary files a/docs/img/configure-feed-transformations.png and /dev/null differ diff --git a/docs/img/create-user.png b/docs/img/create-user.png deleted file mode 100644 index 3c5ba5b9e..000000000 Binary files a/docs/img/create-user.png and /dev/null differ diff --git a/docs/img/edit-calendars.png b/docs/img/edit-calendars.png deleted file mode 100644 index b8f6b5618..000000000 Binary files a/docs/img/edit-calendars.png and /dev/null differ diff --git a/docs/img/edit-fare-rules.png b/docs/img/edit-fare-rules.png deleted file mode 100644 index 2aaf49bd1..000000000 Binary files a/docs/img/edit-fare-rules.png and /dev/null differ diff --git a/docs/img/edit-fares.png b/docs/img/edit-fares.png deleted file mode 100644 index 1c199c0af..000000000 Binary files a/docs/img/edit-fares.png and /dev/null differ diff --git a/docs/img/edit-frequencies.png b/docs/img/edit-frequencies.png deleted file mode 100644 index 24ce9934e..000000000 Binary files a/docs/img/edit-frequencies.png and /dev/null differ diff --git a/docs/img/edit-patterns.png b/docs/img/edit-patterns.png deleted file mode 100644 index dc97fe4f9..000000000 Binary files a/docs/img/edit-patterns.png and /dev/null differ diff --git a/docs/img/edit-routes.png b/docs/img/edit-routes.png deleted file mode 100644 index ac8f62010..000000000 Binary files a/docs/img/edit-routes.png and /dev/null differ diff --git a/docs/img/edit-stops.png b/docs/img/edit-stops.png deleted file mode 100644 index 126e3f755..000000000 Binary files a/docs/img/edit-stops.png and /dev/null differ diff --git a/docs/img/edit-timetables.png b/docs/img/edit-timetables.png deleted file mode 100644 index ad490ba50..000000000 Binary files a/docs/img/edit-timetables.png and /dev/null differ diff --git a/docs/img/feed-transformation-summary.png b/docs/img/feed-transformation-summary.png deleted file mode 100644 index 0e7943b45..000000000 Binary files a/docs/img/feed-transformation-summary.png and /dev/null differ diff --git a/docs/img/otp-deployment-diagram.png b/docs/img/otp-deployment-diagram.png deleted file mode 100644 index 7375798fa..000000000 Binary files a/docs/img/otp-deployment-diagram.png and /dev/null differ diff --git a/docs/img/password-reset-logged-in.png b/docs/img/password-reset-logged-in.png deleted file mode 100644 index ef024ff9d..000000000 Binary files a/docs/img/password-reset-logged-in.png and /dev/null differ diff --git a/docs/img/password-reset-logged-out.png b/docs/img/password-reset-logged-out.png deleted file mode 100644 index bfbd2de0e..000000000 Binary files a/docs/img/password-reset-logged-out.png and /dev/null differ diff --git a/docs/img/pattern-insert-stop.png b/docs/img/pattern-insert-stop.png deleted file mode 100644 index 7cee6c17f..000000000 Binary files a/docs/img/pattern-insert-stop.png and /dev/null differ diff --git a/docs/img/pattern-shape-panel.png b/docs/img/pattern-shape-panel.png deleted file mode 100644 index ef58884e3..000000000 Binary files a/docs/img/pattern-shape-panel.png and /dev/null differ diff --git a/docs/img/pattern-stop-order.png b/docs/img/pattern-stop-order.png deleted file mode 100644 index 8b77afef2..000000000 Binary files a/docs/img/pattern-stop-order.png and /dev/null differ diff --git a/docs/img/pattern-stop-toolbar.png b/docs/img/pattern-stop-toolbar.png deleted file mode 100644 index 98633237d..000000000 Binary files a/docs/img/pattern-stop-toolbar.png and /dev/null differ diff --git a/docs/img/project-profile.png b/docs/img/project-profile.png deleted file mode 100644 index 7a0d19bf0..000000000 Binary files a/docs/img/project-profile.png and /dev/null differ diff --git a/docs/img/quick-access-toolbar.png b/docs/img/quick-access-toolbar.png deleted file mode 100644 index aa0c807a7..000000000 Binary files a/docs/img/quick-access-toolbar.png and /dev/null differ diff --git a/docs/img/schedule-exception.png b/docs/img/schedule-exception.png deleted file mode 100644 index 25e2d4406..000000000 Binary files a/docs/img/schedule-exception.png and /dev/null differ diff --git a/docs/img/schedule-toolbar.png b/docs/img/schedule-toolbar.png deleted file mode 100644 index 17bf270d1..000000000 Binary files a/docs/img/schedule-toolbar.png and /dev/null differ diff --git a/docs/img/select-trips.png b/docs/img/select-trips.png deleted file mode 100644 index ccd485a64..000000000 Binary files a/docs/img/select-trips.png and /dev/null differ diff --git a/docs/img/user-admin.png b/docs/img/user-admin.png deleted file mode 100644 index a42b4d52e..000000000 Binary files a/docs/img/user-admin.png and /dev/null differ diff --git a/docs/img/user-profile.png b/docs/img/user-profile.png deleted file mode 100644 index 6b17919eb..000000000 Binary files a/docs/img/user-profile.png and /dev/null differ diff --git a/docs/img/view-all-stops.png b/docs/img/view-all-stops.png deleted file mode 100644 index c1641d5e9..000000000 Binary files a/docs/img/view-all-stops.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md index 12c29580e..5406b14f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,9 @@ -# MTC Transit Data Tools +# {{ product_name }} +The {{ product_name }} suite provides web-based tools for creating, managing, evaluating, and publishing transit data, specifically data stored in the General Transit Feed Specification (GTFS) format. -The MTC Transit Data Tools suite provides web-based tools for creating, managing, evaluating, and publishing transit data, specifically data stored in the General Transit Feed Specification (GTFS) format. - -![screenshot](img/feed-profile.png) +![feed-profile](https://datatools-builds.s3.amazonaws.com/docs/intro/feed-profile.png) To get started, click a topic from the table of contents on the left pane. ## Contact -If users need to report bugs or require further assistance, please email [transitdata@511.org](mailto:transitdata@511.org). +If users need to report bugs or require further assistance, please email [{{ support_email }}](mailto:{{ support_email }}). diff --git a/docs/requirements.txt b/docs/requirements.txt index 42f5e6a6a..9399b6b57 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ # Pin the mkdocs version so that readthedocs uses the same version as this repo's CI job. # (Otherwise because the project is from before 2019, readthedocs uses a super old mkdocs 0.17.3.) -mkdocs==1.3.0 \ No newline at end of file +mkdocs==1.6.1 +mkdocs-macros-plugin diff --git a/docs/style.css b/docs/style.css index 7d58bedba..9f362c349 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,10 +1,17 @@ -img[alt=screenshot] { width: 100%; } +body { + font-family: Arial, Helvetica, sans-serif; +} /*Ignore first heading in page in TOC list*/ .toctree-l4:first-child { display: none; } +h1, h2, h3 { + color: #015f97; + font-family: Arial, Helvetica, sans-serif; +} + img:not(img[alt=screenshot]) { min-width: 300px; margin-left: auto; @@ -15,3 +22,25 @@ img:not(img[alt=screenshot]) { .section p:has(img:not(img[alt="screenshot"])) { text-align: center; } + +/* Make all images responsive */ +img { + display: block; + margin: auto; +} + +img[alt=screenshot] { + width: 100%; +} + +/* Center all iframes */ +iframe { + display: block; + margin: 12px auto; +} + +/* Ignore the first heading in the page in TOC list */ +li.toctree-l3:first-child { + display: none; +} + diff --git a/docs/user/add-deployment-server.md b/docs/user/add-deployment-server.md index bf89519ca..8e6bbfe8a 100644 --- a/docs/user/add-deployment-server.md +++ b/docs/user/add-deployment-server.md @@ -1,29 +1,29 @@ -# Adding an OTP Deployment Server in Data Tools +# Adding an OTP Deployment Server in TRANSIT-data-tools Assumptions: -* [X] You are an admin for Data Tools. +* [X] You are an admin for TRANSIT-data-tools. * [X] You have [set up OTP UI and backend servers on AWS](./setting-up-aws-servers.md). * [X] You have a private key file (usually ends in `.pem`) to connect to that AWS environment and EC2 servers via ssh. -From `Administration > Deployment servers`, click on `+ Add Server`. +From `Administration > Deployment servers`, click `+ Add Server`. ## General Server Properties | Property | Description | |----------|-------------| | Name | A descriptive display name for the server. | -| Public URL | The URL where the public can access the Data Tools UI, e.g. `https://otp-mod-ui.ibi-transit.com`. It is typically the CNAME of the CloudFront mirror of the AWS S3 bucket you created or picked for this deployment. | +| Public URL | The URL where the public can access the TRANSIT-data-tools UI, e.g. `https://otp-mod-ui.ibi-transit.com`. It is typically the CNAME of the CloudFront mirror of the AWS S3 bucket you created or picked for this deployment. | | Internal URLs (Optional) | The URL(s) based on the UI server IP address(es). | -| S3 bucket name | The name of the AWS S3 bucket you created or picked for this deployment, where Data Tools will share files with the OTP servers. | +| S3 bucket name | The name of the AWS S3 bucket you created or picked for this deployment, where TRANSIT-data-tools will share files with the OTP servers. | | Admin access only? | Check this option to only allow logins from Data Tool admins. | | Project specific? (Optional) | Select a project to only allow the GTFS feeds of that project (e.g. within a region) to be deployed to this server. Leave blank to remove the project restriction. | -|AWS Role|The IAM role that the Data Tools application must assume in order to access AWS resources (e.g., writing to S3 buckets or starting EC2 machines). See [Delegate Third Party Access](../setting-up-aws-servers#delegate-third-party-account-access) for more info.| +|AWS Role|The IAM role that the TRANSIT-data-tools application must assume in order to access AWS resources (e.g., writing to S3 buckets or starting EC2 machines). See [Delegate Third Party Access](../setting-up-aws-servers#delegate-third-party-account-access) for more info.| | Use elastic load balancer (ELB) | **We recommend using an elastic load balancer (ELB)**. Behind the scenes, a new server is initialized and added to the load balancer, and old servers are removed and destroyed without interruption to the user.

The load balancer also allows instantiating multiple OTP servers on large deployments. (You can start, add, or remove more than one OTP server to the load balancer.) ## Load Balancer Properties -(Applies to Data Tools versions prior to October 2019.) +(Applies to TRANSIT-data-tools versions prior to October 2019.) | Property | Description | |----------|-------------| diff --git a/docs/user/deploying-feeds.md b/docs/user/deploying-feeds.md index 17cb2dba3..4b51976a5 100644 --- a/docs/user/deploying-feeds.md +++ b/docs/user/deploying-feeds.md @@ -4,7 +4,7 @@ Assumptions: * [X] You have [loaded a GTFS feed into a project](./managing-projects-feeds.md). * [X] You have a deployment server available [(example: AWS)](./add-deployment-server.md). -* [X] [An osm-lib server has been set up](https://github.com/conveyal/osm-lib) for Data Tools to fetch Open Streets Map (OSM) data. +* [X] [An osm-lib server has been set up](https://github.com/conveyal/osm-lib) for TRANSIT-data-tools to fetch Open Streets Map (OSM) data. ## Executing a deployment @@ -13,14 +13,14 @@ To deploy or update GTFS feeds to OTP: 1. Open a project. 2. Click on the `Deployments` tab. 3. (Optional) To create a new deployment, click `+ New Deployment`, enter a name, then press or click Enter. -4. Click the name of the deployment to execute. A summary of feeds and existing deployments (if available) are shown for your review. +4. Click on the name of the deployment to execute. A summary of feeds and existing deployments (if available) are shown for your review. 5. Remove the feeds you don't need from the deployment. For the remaining feeds, select the correct feed version. 6. In the `OTP Configuration` pane: * Select the latest OTP version (the first one in the list). * Check `Build graph only` to only generate and output a graph file on EC2 to the S3 server (no OTP server will be running after the graph is generated). * The R5 option is not used. 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. +8. Click on 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 @@ -32,7 +32,7 @@ The pane also has an option to upload Custom POI CSV files. These files contain ## Watching deployments take place -After click Deploy, you can watch the deployment progress from the right-hand panel: +After you click `Deploy`, you can watch the deployment progress from the right-hand panel: 1. The data bundle is uploaded to S3. 2. One EC2 server is commissioned. diff --git a/docs/user/editor/calendars.md b/docs/user/editor/calendars.md deleted file mode 100644 index cf9fcd401..000000000 --- a/docs/user/editor/calendars.md +++ /dev/null @@ -1,42 +0,0 @@ -# Calendars - -## Editing calendars - -To begin editing calendars, click the 📅 button on the lefthand navigation bar. - -![screenshot](../../img/edit-calendars.png) - -Choose a calendar from the list to begin editing. To create a new calendar, click `+ New calendar`. **Note:** as with all newly created items (except patterns), the new calendar will not be saved until the save icon (💾) is clicked. - -## Calendar details - -- **Service ID** - unique ID for the calendar -- **Description** - optional description for calendar (defaults to initial days of week specified) -- **Days of service** - days of week on which the service operates -- **Start/End dates** - the first and last day of that service assigned to the calendar should run - -## Editing schedule exceptions - -Schedule exceptions allow users to define days where special services should replace the regularly operating calendars. To create a schedule exception, click the `Exceptions` tab and then click `+ New exception` (replaces the "New calendar" button). - -![schedule exception](../../img/schedule-exception.png) - -## Exception details - -- **Name** - name of schedule exception -- **Schedule to run** - the chosen schedule that should replace the regularly operating calendars (see below Exception types) -- **Dates** - one or more dates to which the schedule exception applies - -## Exception types - -There are a number of built-in exception types (or available schedules to run) that allow for a high degree of flexibility when assigning special services. - -- **[Su, M, Tu, W, Th, F, Sa]** - replaces all service for the specified dates with the calendar(s) that operate on the chosen day of the week -- **No service** - indicates that no service of any kind should operated on the specified dates -- **Custom** - replace all service for the specified dates with trips operating on the one or more calendars specified with this option. E.g., run only `holiday` and `holiday-plus` calendar on Thanksgiving Day. -- **Swap** - similar to the **Custom** option, however this option allows for removing one or more specific calendars for the specified dates and/or adding one or more specific calendars. This option is especially useful if only certain routes have altered service on specific dates. For example, a user could remove the `weekday-route-1` calendar and add the `special-route-1` calendar. - -## Editing schedules -Click `Edit schedules` to begin creating or editing trips/frequencies for a trip pattern. You will be redirected to the Schedule Editor. For more information on creating schedules for a pattern, see [Trips](schedules). - -**Note**: At least one route, pattern and calendar must have been created to edit schedules. diff --git a/docs/user/editor/fares.md b/docs/user/editor/fares.md index 5ac92a206..89b195cea 100644 --- a/docs/user/editor/fares.md +++ b/docs/user/editor/fares.md @@ -1,33 +1,52 @@ # Fares -## Editing fares +## Fares Overview -To begin editing fares, click the fare ticket button on the lefthand navigation bar. +### Fare attributes -![screenshot](../../img/edit-fares.png) +Fare attributes describe the basic information about a fare including the price, currency type and transfer information. See the [GTFS specification fare attribute reference](https://gtfs.org/schedule/reference/#fare_attributestxt) for more information. +### Fare rules -Choose a fare from the list to begin editing. To create a new fare, click `+ New fare`. **Note:** as with all newly created items (except patterns), the new fare will not be saved until the save icon (💾) is clicked. +Fare rules describe how much riders pay to use a transit system, based on factors such as distance traveled, time of day, and type of fare media used (such as a mobile app). In other words, they govern how fare attributes are applied. See the [GTFS specification fare rule reference](https://gtfs.org/schedule/reference/#fare_rulestxt) for more information. -## Fare attributes -Fare attributes describe the basic information about a fare. Full details on fare attributes can be found at the [GTFS specification reference](https://developers.google.com/transit/gtfs/reference/fare_attributes-file). +## Editing/Creating Fares -## Fare rules +To begin editing fares, click the fare ticket button on the lefthand navigation bar (outlined in red). -To edit fare rules, you must first create and save a fare with attributes. After choosing a fare, click the `Fare rules` tab and define one or more rules for this fare using the following types: +Choose a fare from the list to begin editing. To create a new fare, click `+ New fare`, or, if this is the first fare being created for this feed, click `+ Create first fare` (highlighted in yellow). + +![fare-tab](https://datatools-builds.s3.amazonaws.com/docs/fares/fare-tab.png) + +### Edit fare attributes +In the `Attributes` tab, required and optional information about the fare can be entered, like `fare_id`, `price` and `currency_type`. + +**Note: Be sure to click the save button (💾) after changes to fare attributes or fare rules are made. Clicking save after adding attributes will allow you to edit fare rules.** + +### Edit fare rules + +To define fare rules, you must first create fare zones, which are explained in the next section. + +To edit fare rules, you must first create and save a fare with attributes. After choosing a fare, click the `Rules` tab and define one or more rules for this fare using the following types: 1. **Route** - applies to any itinerary that includes the route 2. **From/to zone** - applies to any itinerary that travels from the origin zone to the destination zone 3. **Contains zone** - applies to any itinerary that passes through *each* `contains` zone -**Note:** fare rules can be tricky, see the [GTFS specification reference](https://developers.google.com/transit/gtfs/reference/fare_rules-file) for more information on how fare rules apply. - -![screenshot](../../img/edit-fare-rules.png) +![Fare rule editor](https://datatools-builds.s3.amazonaws.com/docs/fares/edit-fare-rules.png) -## Creating fare zones +### Creating fare zones -To create a fare zone for use in fare rules, you must first select a stop that you would like to include in the zone. Click in the `zone_id` dropdown and begin typing the new `zone_id`. Click `Create new zone: [zone_id]` and then save the stop. Repeat for as many zones as needed. +To create a fare zone, you must first select a stop that you would like to include in the zone by clicking the location pin icon on the sidebar and selecting one of the stop names. Next, click in the `zone_id` dropdown and begin typing the new `zone_id`. Click `Create new zone: [zone_id]` and then save the stop. Repeat for as many zones as needed. -![screenshot](../../img/add-fare-zone.png) +![Add fare zone](https://datatools-builds.s3.amazonaws.com/docs/fares/add-fare-zone.png) -Once created and assigned to one or more stop, fare zones can be used when defining fare rules for **From/to zone** or **Contains zone**. +### Tutorial Video: Editing/Creating Fares + diff --git a/docs/user/editor/introduction.md b/docs/user/editor/getting-started.md similarity index 94% rename from docs/user/editor/introduction.md rename to docs/user/editor/getting-started.md index a15c8b3bf..0721878aa 100644 --- a/docs/user/editor/introduction.md +++ b/docs/user/editor/getting-started.md @@ -1,4 +1,4 @@ -# Introduction +# Getting Started ## Getting started @@ -22,6 +22,8 @@ When editing routes, stop, calendars, and any other entities in the Editor, you The primary tables in GTFS (feed info, routes, stops, calendars, and fares) all correspond to items in the lefthand navigation bar, which allow you to create, edit, or delete records for each of these tables. +![nav-bar](https://datatools-builds.s3.amazonaws.com/docs/intro/nav-bar.png) + Some tables are nested underneath these primary tables. Here's how to find them and what they're called in the Editor: - **[Routes](routes)** @@ -36,7 +38,7 @@ Some tables are nested underneath these primary tables. Here's how to find them In the bottom, righthand corner of the Editor, you'll find the quick access toolbar that has a few convenient features you might need while editing. -![quick access toolbar](../../img/quick-access-toolbar.png) +![quick access toolbar](https://datatools-builds.s3.amazonaws.com/docs/intro/quick-access-toolbar.png) From left to right, these functions are: diff --git a/docs/user/editor/patterns.md b/docs/user/editor/patterns.md index e68b63d7b..401d595ca 100644 --- a/docs/user/editor/patterns.md +++ b/docs/user/editor/patterns.md @@ -1,33 +1,19 @@ # Trip Patterns -## Editing trip patterns +## Patterns +Patterns refer to the recurring schedules and frequencies of transit trips. They can be thought of as a template for a particular route, indicating the days of the week and times of day that trips will be available, as well as the frequency of service during those periods. -To begin editing trip patterns (or patterns), first select or create a route. Click the `Trip patterns` tab. +## Editing/Creating Trip Patterns -![screenshot](../../img/edit-patterns.png) +To begin editing trip patterns, first click the `Routes` tab (outlined in red). Then, click or create a route Click the `Trip patterns` tab, and start editing any relevant fields including: -Choose a pattern from the list to begin editing. To create a new pattern, click `+ New pattern`. - -## Pattern toolbar - -### Zoom to pattern extents -Clicking the 🔍 button (in the top toolbar) with a pattern selected adjusts the map view to show the entire pattern you are editing. - -### Duplicate pattern -Creates a duplicate of the active pattern (duplicated pattern name becomes `[Pattern name] copy`) if you need to create a similar, but different trip pattern. - -### Reverse pattern -To reverse the sequence of stops for a pattern, click the yellow opposing arrows button. **Note**: this is a destructive action and should usually only be used with Duplicate Pattern. - -### Delete pattern - -Deletes the active pattern. **Note**: deleted patterns cannot be recovered. - -## Pattern details - -- **Name** – the name of the trip pattern within the route, for example a service that runs the full length of the route or a service that only runs part of the length of the route. +- **Name:** The name of the pattern within the route is initially set by default to a designation like "27 stops from Willowridge Rd at Richgrove Dr to Kipling Station (13 trips)." However, it can be customized to a more meaningful label if desired. +- **Type:** Specifies whether the pattern uses timetables or schedules. For more information on the differences between schedules and timetables, consult [Schedules](schedules.md). +- **Direction:** Specifies whether the pattern is inbound or outbound. This corresponds to the `direction_id` field in GTFS. All trips associated with this pattern will be assigned the direction provided here. +- **Editing schedules:** +Click `Edit schedules` to begin creating or editing trips/frequencies for a trip pattern. You will be redirected to the Schedule Editor. For more information on creating schedules for a pattern, see [Trips](schedules). - **Use timetables/frequencies** - whether the pattern uses timetables, i.e., individual entries for each trip made over the course of a day, or frequencies, which define varying frequencies (or headways) according to time intervals over the course of a day. - **Direction** - corresponds to the `direction_id` field in GTFS. All trips associated with this pattern will be assigned the direction provided here. @@ -36,6 +22,24 @@ Click `Edit schedules` to begin creating or editing trips/frequencies for a trip For more information on creating schedules for a pattern, [see the Trips section](schedules). +To add a pattern, click the `+ New pattern` button (highlighted in yellow). + +![edit-pattern](https://datatools-builds.s3.amazonaws.com/docs/patterns/edit-pattern.png) + +### Pattern editing tools: +The pattern toolbar contains several helpful buttons to help with the pattern editing process. + + + +- **Zoom to pattern extents:** +Clicking the search (🔍) button (in the top toolbar) with a pattern selected adjusts the map view to show the entire pattern you are editing. +- **Duplicating pattern:** +Used to create a similar, but different trip pattern. When duplicating of the active pattern, its name becomes `[Pattern name] copy`. +- **Reverse pattern:** +To reverse the sequence of stops for a pattern, click the button with opposing arrows. Note: this is a destructive action and should usually only be used after duplicating a pattern. +- **Delete pattern:** Deletes the active pattern. Note: deleted patterns cannot be recovered. + + ## Stop sequence ### Adding stops To begin constructing a pattern, click `+ Add stop`. You can then search for stops to add by name in the dropdown that appears or zoom into the map and add stops by clicking on them and clicking `+ Add stop`. @@ -45,30 +49,25 @@ To begin constructing a pattern, click `+ Add stop`. You can then search for sto As you add stops, the pattern shape will update according to the street network. Keep selecting and adding stops in the sequence the route passes through them until the route is complete. ### Inserting stops mid-sequence -If you realize you have missed a stop from the sequence it can be added in after the fact. When adding via the dropdown, stops can only be added to the end of the sequence; however, if you're adding via the map, you can choose where exactly to insert the stop. +First, click the `From Stops` button so that Datatools knows the pattern can be regenerated by editing stops. If you realize that you have missed a stop from the sequence, it can be added after the fact. When adding via the dropdown, stops can only be added to the end of the sequence; however, if you're adding via the map, you can choose where exactly to insert the stop. -![insert stop from map](../../img/pattern-insert-stop.png) +![insert stop from map](https://datatools-builds.s3.amazonaws.com/docs/patterns/pattern-insert-stop.png) ### Pattern stop toolbar The pattern stop toolbar can be found by clicking the pattern stop in the map view. -![pattern stop toolbar](../../img/pattern-stop-toolbar.png) - -- **Save** - saves any edits to the travel and dwell times. -- **Edit** - allows user to begin editing the stop's details (clicking the browser **Back** button will bring you back to editing the pattern). **Note**: be sure you have saved any pattern information before jumping to edit a stop. -- **Remove** - removes the stop from the pattern. -- **Add** - add the stop to the sequence at an additional location. This is useful for patterns that might visit the same stop more than once (e.g., loop routes). +- **Save:** saves any edits to the travel and dwell times. +- **Edit:** allows user to begin editing the stop's details (clicking the browser **Back** button will bring you back to editing the pattern). **Note**: be sure you have saved any pattern information before jumping to edit a stop. +- **Remove:** removes the stop from the pattern. +- **Add:** add the stop to the sequence at an additional location. This is useful for patterns that might visit the same stop more than once (e.g., loop routes). ### Changing the order of stops -If a stop is in the wrong order, you can click and drag the stop from the list view into the correct sequence. The sequence displayed on the stop being dragged will only update after being dropped into place. +First, click the `From Stops` button so that Datatools knows the pattern can be regenerated by editing stops. If a stop is in the wrong order, you can click and drag and drop the stop from the list view into the correct sequence. The sequence displayed on the stop being dragged will only update after being dropped into place. -![drag and drop pattern stop](../../img/pattern-stop-order.png) - -**Note**: if inserting stops mid-sequence or changing the order of stops, the pattern shape will not update automatically. You must edit the pattern manually or click the `Create` button to regenerate the pattern shape from the stops. +![drag and drop pattern stop](https://datatools-builds.s3.amazonaws.com/docs/patterns/pattern-stop-order.png) ### Removing stops -Stops can be removed from the pattern by clicking the red 🗑 button found in the expanded stop card or by clicking the pattern stop on the map view. - +Stops can be removed from the pattern by clicking the red trash (🗑) icon found in the expanded stop card or by clicking the pattern stop on the map view. ## Stop timings ### Pattern stop colors @@ -86,26 +85,41 @@ Once you have adjusted the stop timings, another warning will appear prompting y ![normalize pattern stops](../../img/normalize-stop-times.png) ### Calculate timings -The average speed for the route can be used to calculate all the time gaps between stops in one go. Once the stops have been added and the pattern alignment is drawn simply click **Calc. Times** and the time between stops will automatically fill in. +The average speed for the route can be used to calculate all the time gaps between stops in one go. A few parameters can be specified before calculating times: - **Average speed** - average speed (in km/hr) to use when calculating timings - **Dwell time** – allows you to assign the same dwell time to all stops within a trip pattern -### Manual timings -Individual timings for each pattern stop can also be assigned either through expanded the stop card in the list of stops or via clicking the pattern stop on the map view. +Once the stops have been added and the pattern alignment is drawn simply click `Calc. times` and the time between stops will automatically fill in. -- Travel time – the time it took to get from the previous station (should be 00:00 on the first stop of the sequence) -- Dwell time – the time the vehicle rests at the stop +Another option is to click the `Normalize stop times` button above all the stops make all arrival and departure times on the pattern match a default travel time for each stop. +### Manual timings +Specific timings for each pattern stop can be set by either clicking on the stop on the map view or expanding the stop card in the list view. The two types of times that can be edited are: + +- **Travel time:** the time it took to get from the previous station (should be 00:00 on the first stop of the sequence) +- **Dwell time:** the time the vehicle rests at the stop + +### Tutorial Video: Editing/Creating Patterns +The following video demonstrates how to create patterns as outlined above, in a step by step manner. + +
-## Creating pattern alignments -As mentioned above, pattern shapes will begin to draw automatically as stops are added in sequence. However, if you need to make adjustments to the auto-generated shape, clicking `Edit` will allow you to begin editing the shape segments between stops. +## Editing/Creating Pattern Geometry +As mentioned above, pattern shapes will begin to draw automatically as stops are added in sequence. However, if you need to make adjustments to the auto-generated shape, clicking `Edit pattern geometry` will allow you to begin editing the shape segments between stops. ### Basic editing -Once editing a pattern shape, there are a few behaviors you should be aware of: +While editing a pattern shape, there are a few behaviors you should be aware of: - Dragging a handle (✕) will create a new alignment between stops or surrounding handles - Clicking the pattern will add additional handles to the segment @@ -113,23 +127,25 @@ Once editing a pattern shape, there are a few behaviors you should be aware of: ### Shape toolbar #### Before editing -- **Edit** - begin editing the pattern shape -- **Delete** - delete the pattern shape -- **Create** - creates alignment that follows streets from pattern stops +- **Edit:** Begin editing the pattern shape +- **Delete:** Delete the pattern shape +- **Create:** Creates alignment that follows streets from pattern stops #### While editing -- **Save** - Save changes to pattern shape and leave editing mode. -- **Undo** - Undo last change. -- **Cancel** - Cancel edits and leave editing mode. +- **Save:** Save changes to pattern shape and leave editing mode +- **Undo:** Undo last change +- **Cancel:** Cancel edits and leave editing mode ### Edit settings - **Snap mode selector** - Use the dropdown selector to choose how pattern segments between handles and stops are aligned. The available snapping modes are: - - **Snap to streets** - snaps segments between handles and stops to streets. Can toggle the 'Avoid highways in routing' checkbox as needed. - - **Snap to rail** - snaps segments between handles and stops to rail. - - **None** - segments will form straight lines between handles and stops. -- **Snap to stops** - keeps shape segments snapped to stops. If unchecked, stop handles will become draggable. In most cases, this setting should remain checked. -- **Show stops** - toggles whether stops are visible because sometimes stop markers can get in the way when trying to draw shapes. + - **Snap to streets** - snaps segments between handles and stops to streets. Can toggle the 'Avoid highways in routing' checkbox as needed. + - **Snap to rail** - snaps segments between handles and stops to rail. + - **None** - segments will form straight lines between handles and stops. +- **Hide stop handles:** Keeps shape segments snapped to stops. If unchecked, stop handles will become draggable. In most cases, this setting should remain checked. +- **Hide inactive segments:** Toggles whether to show segments that are not currently being edited. +- **Show stops** - Toggles whether stops are visible because sometimes stop markers can get in the way when trying to draw shapes. +- **Show tooltips:** Toggles whether to show tips when hovering over a segment, e.g. "Click to edit segment" or "Click to add control point". ![pattern shape editing options](../../img/pattern-shape-panel.png) @@ -137,12 +153,23 @@ Once editing a pattern shape, there are a few behaviors you should be aware of: There are a few different editing modes that allow for the quick and easy creation of pattern shapes: -- **Drag handles** (default) - drag handles to change the pattern segment between stops. This mode is also in effect during the advanced modes listed below. -- **Add stop at click** - at each click on the map, a new stop is generated and the pattern is extended to this stop. -- **Add stop at interval** - at each click on the map, stops are generated along the auto-generated pattern extended to the map click at the user-defined spacing interval from 100 to 2000 meters. -- **Add stop at intersection** (experimental, not available in all regions) - at each click on the map, stops are generated along the auto-generated pattern extended to the map click according to the user-defined parameters: - - **Offset from intersection** - distance the stop should be placed from the intersection - - **Before/after** - whether stop should be placed before or after intersection - - **Every *n* intersections** - the number of intersections at which each new stop should be placed +- **Drag handles:** (default) Drag handles to change the pattern segment between stops. This mode is also in effect during the advanced modes listed below. +- **Add stop at click:** At each click on the map, a new stop is generated and the pattern is extended to this stop. +- **Add stop at interval:** At each click on the map, stops are generated along the auto-generated pattern extended to the map click at the user-defined spacing interval from 100 to 2000 meters. +- **Add stop at intersection:** (experimental, not available in all regions) - at each click on the map, stops are generated along the auto-generated pattern extended to the map click according to the user-defined parameters: + - **Offset from intersection:** Distance the stop should be placed from the intersection + - **Before/after:** Whether the stop should be placed before or after intersection + - **Every *n* intersections:** The number of intersections at which each new stop should be placed **Note**: the last three advanced editing modes should only be used when creating routes in new areas where stops don't already exist. + +### Tutorial Video: Editing Pattern Geometry +The following video demonstrates the steps for editing patterns, as outlined above. + diff --git a/docs/user/editor/routes.md b/docs/user/editor/routes.md index 6e3e5589f..d187c4be2 100644 --- a/docs/user/editor/routes.md +++ b/docs/user/editor/routes.md @@ -1,36 +1,46 @@ # Routes +In GTFS, a route is a group of trips that follow one or more patterns and operate throughout a service day. It is identified by a unique route_id and contains information such as the route name, type, and operating agency. For more information, view the [GTFS specification route reference](https://gtfs.org/schedule/reference/#routestxt) -## Editing routes +## Editing/Creating Routes -To begin editing routes, click the 🚍 button on the lefthand navigation bar. +To begin editing routes, click the bus (🚍) button on the lefthand navigation bar. -![screenshot](../../img/edit-routes.png) + -Choose a route from the list or search by route name in the dropdown. To create a new route, click `+ New route`. **Note:** as with all newly created items (except patterns), the new route will not be saved until the save icon (💾) is clicked. +Choose a route from the list or search by route name in the dropdown. To create a new route, click `New route` or `+ Create first route` if this is the first route being created. -## Zoom to route extents +### Zoom to route extents Clicking the 🔍 button (in the top toolbar) with a route selected adjusts the map view to show the entire route (i.e., all patterns) you are editing. ## Route details -Some of the fields to be filled in are required before you can ‘Save and Continue’ and move to the next step, these are: +The following fields are required before you can hit `Save and Continue`: -- **Short name** – name of the service/route, this may just be a number -- **Long name** – the full name of the route, often including the origin and destination -- **Route Type** – the type of vehicle/transport used on the route -Other fields in this section are optional and do not have to be filled in, these are: -- **Description** – a description of the route, do not simply repeat the information in ‘Long name’ -- **URL** – a link to a webpage with information on the specific route, such as the timetable -- **Color** – if a route has a color (for use in trip planners etc) it can be assigned here -- **Text color** – if a route has a text color (for use in trip planners etc) it can be assigned here -- **Comments** – any additional information about the route can be placed here +- **Status:** Takes the following values: + - **In-Progress:** Showing a route has not been completely entered + - **Pending Approval:** A route has all the information entered and is awaiting a senior person to sign it off + - **Approved:** All the above stages have been completed +- **Publicly Visible?** This must be set to "Yes" for the route to be included in a GTFS output. +- **route_id:** An identifier for the route. A randomly generated ID is provided by default. +- **route_short_name:** Name of the service/route, this may just be a number +- **route_long_name:** The full name of the route, often including the origin and destination +- **route_type:** The type of vehicle/transport used on the route -Once all the required fields, and any of the desired optional fields, are filled in click ‘Save and continue’. +The following fields are optional: +- **agency_id:** The agency identifier from the defined agencies. Generally this field is automatically populated. +- **route_desc:** A description of the route, do not simply repeat the information in ‘Long name’ +- **route_sort_order:** Orders the routes for presentation to GTFS consumers. Routes with smaller route_sort_order values should be displayed first +- **route_url:** A link to a webpage with information on the specific route, such as the timetable +- **route_color:** If a route has a color (for use in trip planners etc) it can be assigned here +- **route_text_color:** If a route has a text color (for use in trip planners etc) it can be assigned here +- **Is route wheelchair accessible?** Either "Yes", "No", or "No Information" +- **Route branding URL:** A link to a webpage with route branding information +- **Route branding asset:** A route image -## Review +Once all the required fields and any of the desired optional fields are filled in, click `Save`. -This final page allows you to show if a route has been completely entered, and if it has whether it has been checked and approved for inclusion in the GTFS feed. +**Note:** as with all newly created items (except patterns), the new route will not be saved until the save icon (💾) is clicked. ### Status @@ -54,3 +64,15 @@ Routes in the Pending Approval or In Progress phase will not be publicly visible Once you've created and saved a route, you can then begin creating trip patterns for the route. [Learn about editing trip patterns Âģ](patterns) + +### Tutorial Video: Editing/Creating Routes +This video provides a step-by-step demonstration of how to edit or create a route. + + diff --git a/docs/user/editor/schedules.md b/docs/user/editor/schedules.md index d46c7f8b4..d845ae9e2 100644 --- a/docs/user/editor/schedules.md +++ b/docs/user/editor/schedules.md @@ -1,4 +1,9 @@ -# Trips +# Schedules/Calendars + +## Schedule and Calendar Overview +The schedule editor allows for the creation of trips/frequencies for any combination of route, pattern, and/or calendar. To manage or edit schedules or exceptions, navigate to the `Calendar` tab located in the left-hand menu: + +![calendar-tab](https://datatools-builds.s3.amazonaws.com/docs/schedules/calendar-tab.png) ## Keyboard shortcuts There are a number of keyboard shortcuts for jumping between and modifying trips. To view these, click the `?` in the top righthand corner of the timetable editor. You can also view these at any time while editing trips by typing `?`. The shortcuts are shown below: @@ -8,7 +13,85 @@ There are a number of keyboard shortcuts for jumping between and modifying trips ## Selecting schedules The schedule editor allows users to create trips/frequencies for any route, pattern, and calendar combination. The selectors at the top of the page allow users to navigate between calendars for a given pattern or switch between patterns for a route or even routes within the feed. -Each selection has a set of statistics associated with it to give you a sense of how it fits in with the feed: +### Calendars +Transit calendars in GTFS are used to define the days of the week on which transit services are available. See the [GTFS specification calendar reference](https://gtfs.org/schedule/reference/#calendartxt) for more information. + +### Exceptions +Exceptions are deviations from the regular transit service schedule, such as holidays, special events, cancellations and service disruptions. See the [GTFS specification calendar dates reference](https://gtfs.org/schedule/reference/#calendar_datestxt) for more information. + +### Schedules/Timetable-based routes +Timetable-based routes follow a fixed schedule in which the start time, end time, and all the intermediate stops are pre-defined. + +### Frequency-based routes +Unlike the fixed nature of timetable-based trips, frequency-based trips run at regular intervals, with a fixed amount of time between consecutive trips. Frequency-based service offers more flexibility and easier adjustment to changing demand. Visit [GTFS specification frequency reference](https://gtfs.org/schedule/reference/#frequenciestxt) for more information. + +## Editing/Creating Calendars +To start editing a calendar, click `+ Create first calendar` if this is the first calendar being added or click an existing calendar to begin adding/editing its properties which include: + +- **Service ID:** Unique ID for the calendar +- **Description:** Optional description for calendar (defaults to initial days of week specified) +- **Days of service:** Days of week on which the service operates +- **Start/End dates:** The first and last day of that service assigned to the calendar should run + +**Note: Be sure to click the save button (💾) after changes any changes to calendars are made.** + +### Tutorial Video: Creating/Editing Calendars + +
+ +## Editing/Creating Exceptions +To start editing an exception, click any existing exception (if applicable) on the left pane. You will be able to edit properties such as exception name, customize the exception type, click calendars to add, remove or swap and the time range the exception is applied to. To make a new exception, click `New exception` on the top left of the pane (highlighted in yellow). + + + +You will be able to add or edit properties such as: + +- **Name:** Name of schedule exception +- **Schedule to run:** The chosen schedule that should replace the regularly operating calendars (see below Exception types) +- **Dates:** One or more dates to which the schedule exception applies + +### Exception types + +There are a number of built-in exception types (or available schedules to run) that allow for a high degree of flexibility when assigning special services. + +- **[Su, M, Tu, W, Th, F, Sa]** - replaces all service for the specified dates with the calendar(s) that operate on the chosen day of the week +- **No service** - indicates that no service of any kind should operated on the specified dates +- **Custom** - replace all service for the specified dates with trips operating on the one or more calendars specified with this option. E.g., run only `holiday` and `holiday-plus` calendar on Thanksgiving Day. +- **Swap** - similar to the **Custom** option, however this option allows for removing one or more specific calendars for the specified dates and/or adding one or more specific calendars. This option is especially useful if only certain routes have altered service on specific dates. For example, a user could remove the `weekday-route-1` calendar and add the `special-route-1` calendar. + +**Note: Be sure to click the save button (💾) after changes any changes to exceptions are made.** +### Tutorial Video: Creating/Editing Exceptions + +
+ +## Editing/Creating Timetables +To begin editing a timetable, click the `Edit schedules` button in the top left corner of the screen (highlighed in yellow). + +(Alternatively, if you are in the `Routes` tab (see [Routes](/user/editor/routes/)), click an existing route or route click the `New route` button --> click the `Trip patterns` tab --> select a pattern --> click `Use timetables` in the `Type:` dropdown --> click the `Edit schedules` button) + + + +**Note**: At least one route, pattern and calendar must have been created to edit schedules. + +The selectors located at the top of the page allow users to navigate between calendars for a specific pattern or switch between patterns for a route or multiple routes within the feed. Variations of route, pattern and the schedule can be selected to generate the desired timetable. + + + +Each selection has a set of statistics associated with it shown as a number in a grey or green box, that, when hovered over, provides the following information: - **Route** - \# of trips for the entire route @@ -18,11 +101,22 @@ Each selection has a set of statistics associated with it to give you a sense of - \# of trips for selected pattern - \# of trips in calendar for entire feed -![schedule selector](../../img/timetable-selector.png) +Once a route, pattern and calendar is selected, a timetable with the following trip details will appear: -## Schedule toolbar +- **Block ID** - identifies the vehicle used for the trip +- **Trip ID** - unique identifier for the trip +- **Trip Headsign** - headsign for the trip +- **Arrival/Departure Times** - arrival and departure times (departures shown in grey text) for each stop along the pattern + +![Timetable editor](https://datatools-builds.s3.amazonaws.com/docs/schedules/edit-timetables.png) + +To select trips to offset, duplicate or delete, click the row number on the lefthand side of the row. To toggle selection of all trips, click the box in the upper lefthand corner. -![schedule toolbar](../../img/schedule-toolbar.png) +![Timetable multi-row selection](https://datatools-builds.s3.amazonaws.com/docs/schedules/select-trips.png) + +After trips are selected, navigate to the schedule toolbar at the top right of the screen. + +## Schedule toolbar - **Add trip** - add blank trip (first timepoint is `00:00:00`) - **Duplicate trip(s)** - duplicate the last trip in the spreadsheet or whichever rows are selected @@ -31,40 +125,48 @@ Each selection has a set of statistics associated with it to give you a sense of - **Save** - save all changes - **Offset trip(s)** - specify an offset (`HH:MM`) to offset the last trip in the spreadsheet or whichever rows are selected -## Selecting trips -To select trips to offset, duplicate or delete, click the row number on the lefthand side of the row. To toggle selection of all trips, click the box in the upper lefthand corner. -![select trips](../../img/select-trips.png) +![Schedule toolbar](https://datatools-builds.s3.amazonaws.com/docs/schedules/schedule-toolbar.png) + +** Note: When entering times manually into the schedule editor they will automatically be converted to a standardized format `13:00:00`** -## Recognized time entry formats -When entering times manually into the schedule editor they will automatically be converted to a standardized format `13:00:00`. The following time formats are automatically recognized and converted: +The following time formats are automatically recognized and converted: - 24-hr - `13:00:00` - `13:00` + - `1300` - 12-hr - - `1:00p` + - `1pm` - `1:00pm` - `1:00 pm` + - `1:00:00pm` - `1:00:00 pm` -## Editing timetables -Trip details include: +### Tutorial Video: Editing/Creating Timetables +The following video demonstrates the creation and editing of timetables described above. -- **Block ID** - identifies the vehicle used for the trip -- **Trip ID** - unique identifier for the trip -- **Trip Headsign** - headsign for the trip -- **Arrival/Departure Times** - arrival and departure times (departures shown in grey text) for each stop along the pattern + -![editing timetables](../../img/edit-timetables.png) +
+## Editing/Creating Frequencies +To edit/create frequencies, navigate to the `Routes` tab (see [Routes](/user/editor/routes/)), click an existing route or route click the `New route` button --> click the `Trip patterns` tab --> click a pattern --> click `Use frequencies` in the 'Type:` dropdown --> click the `Edit schedules` button -## Editing frequencies Frequency details include: - **Block ID** - identifies the vehicle used for the trip - **Trip ID** - unique identifier for the trip - **Trip Headsign** - headsign for the trip - **Start/End Times** - define the beginning and end time for the interval over which the frequency operates -- **Headway** - headway (in minutes) that the pattern runs during the time interval +- **Headway** - headway (in seconds) that the pattern runs during the time interval + +Editing frequencies follow the [same editing procedures](#tutorial-video-editingcreating-timetables) as editing timetables. -![editing frequencies](../../img/edit-frequencies.png) + \ No newline at end of file diff --git a/docs/user/editor/stops.md b/docs/user/editor/stops.md index 054b9ee36..86134a0f6 100644 --- a/docs/user/editor/stops.md +++ b/docs/user/editor/stops.md @@ -2,37 +2,66 @@ ## Editing stops -To begin editing stops, click the map marker icon button on the lefthand navigation bar. +To begin editing stops, click the map marker icon button on the lefthand navigation bar (outlined in red). -![screenshot](../../img/edit-stops.png) +![edit-stops](https://datatools-builds.s3.amazonaws.com/docs/stops/edit-stops.png) -## Selecting a stop +### Selecting a stop Choose a stop from the list or search by stop name in the dropdown. You can also **zoom into the map** while the stop list is visible and once you're close enough you'll begin to see stops displayed. Click one to begin editing its details. -## Creating a stop: right-click on map +### Creating a stop -To create a new stop, **right-click on the map** in the location you would like to place the stop. **Note:** as with all newly created items (except patterns), the new stop will not be saved until the save icon (💾) is clicked. +To create a new stop, **right-click on the map** in the location you would like to place the stop. -## Moving a stop +**Note:** as with all newly created items (except patterns), the new stop will not be saved until the save icon (💾) is clicked. -To move a selected stop simply **click and drag the stop to the new location**. Or, if already you know the latitude and longitude coordinates, you can copy these into the text fields. After moving the stop, click save to keep the changes. +### Editing a stop +Once a stop is created or selected, the following parameters are required: +- **Stop ID (`stop_id`):** Identifies a stop, station, or station entrance. +- **Location (`stop_lat/stop_lon`):** These are defined by latitude and longitude. **Note:** Stop locations should have an error of no more than four meters when compared to the actual stop position. - +### Moving a stop -## View all stops for feed +To move a selected stop, **click and drag the stop to the new location**. Or, if you already know the latitude and longitude coordinates, you can copy these into the text fields. After moving the stop, click save to keep the changes. + +### View All Stops for a Feed To view all stops for a feed, hover over the map layers icon (in the top, lefthand corner of the map) and turn on the `Stop locations` layer. When you do, you'll see all of the stops (which appear as grey circles) for the feed even at wide zoom levels. This layer can be viewed whether or not the stop list is visible, so it can be helpful for users who would like to view stop locations alongside routes or trip patterns. -![screenshot](../../img/view-all-stops.png) + Clicking on a stop shown in this layer will select the stop for editing, but be careful—it can be tricky to select the right stop from very far away! +### Tutorial Video: Editing/Creating Stops +The following video demonstrates the creation and editing of stops outlined below, in a step by step manner. The video covers: +- Adding stops +- Editing stop positions +- Editing stop details +- Showing all stops on map interface + + + OLD POSITION --X--> O + // O --------------------------> O + if (!movedFromEnd) { + // Delete old "to" segment control points and segments, no "to" segment if we're moving from the end + // $FlowFixMe + const previousToSegments = previousToStopControlPoint.cpIndex - movedStopControlPoint.cpIndex; // Semi-colon for babel parsing. + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, movedStopControlPoint.cpIndex, previousToSegments) + } + + if (!movedFromStart) { + // Delete old "from" segment control points and segments, no "from" segment if we're moving from the start + // $FlowFixMe + const previousFromSegments = movedStopControlPoint.cpIndex - previousFromStopControlPoint.cpIndex; // Semi-colon for babel parsing. + // $FlowFixMe + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, previousFromStopControlPoint.cpIndex, previousFromSegments) + } + + return [deletedControlPoints, deletedSegments] +} + +/** + * Method to remove a segment and insert a new one that points to the new stop that is being inserted. + * TODO: Refactor this method to make things cleaner. + */ +const removeNewSegments = ( + deletedControlPoints: Array, + deletedSegments: Array, + movedForward: boolean, + movedToEnd: boolean, + movedToStart: boolean, + newPatternStopSequence: number, + newToStopControlPoint: StopControlPoint, + stopControlPoints: Array +) => { + // The segment that we remove depends on if we are moving forwards or backwards (see diagram). If we're moving forwards we need to modify the old "to" segment, if we're moving backwards the "from" segment. + // Case: moving backward Case: moving forward + // 0 --x--> 0 ------> 0 -------> 0 0 -----> 0 ------> 0 ---x---> 0 + // ↑←←←←←←←←←←←←←←←←←←←←←← 0 0 →→→→→→→→→→→→→→→→→→→↑ + // We need to initialize newToStopControlPoint to their default values if the first check fails (they are still used later if we move to start or end) + if (!movedToStart && !movedToEnd) { // When you moved to the start or end, there's no previous segment to remove, only one to add. + let newSegmentsFromStop // This may differ from the fromStopControlPoint defined earlier because of the backwards and forwards cases for new segment deletion. + if (movedForward) { + newSegmentsFromStop = stopControlPoints[newPatternStopSequence + 1] + const numberNewSegments = newToStopControlPoint.cpIndex - newSegmentsFromStop.cpIndex; // Semi colon for babel parsing. + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, newSegmentsFromStop.cpIndex, numberNewSegments) + } else { // moved backward + newSegmentsFromStop = stopControlPoints[newPatternStopSequence - 1] + const numberNewSegments = newToStopControlPoint.cpIndex - newSegmentsFromStop.cpIndex; + [deletedControlPoints, deletedSegments] = updateControlPointsAndSegments(deletedControlPoints, deletedSegments, newSegmentsFromStop.cpIndex, numberNewSegments) + } + } + return [deletedControlPoints, deletedSegments] +} + +/** + * Method to insert the new segment replacing the segments in the "old" position, the new from segment, and the new to segment if they require insertion. + */ +const insertSegments = async ( + clonedPatternSegments: Array, + insertionPoint: number, + movedFromEnd: boolean, + movedFromStart: boolean, + movedStopControlPoint: StopControlPoint, + movedToEnd: boolean, + movedToStart: boolean, + newFromStopControlPoint: StopControlPoint, + newToStopControlPoint: StopControlPoint, + previousFromStopControlPoint: StopControlPoint, + previousToStopControlPoint: StopControlPoint +) => { + // Insert the rearranged old segment, there is none if we moved from the start or the end + if (!movedFromStart && !movedFromEnd && previousFromStopControlPoint) { // previousFromStopControlPoint check to make flow happy + clonedPatternSegments = await generateSegmentAndInsert(clonedPatternSegments, previousFromStopControlPoint, previousToStopControlPoint, previousFromStopControlPoint.cpIndex) + } + // Insert the new from segment + if (!movedToStart) { + clonedPatternSegments = await generateSegmentAndInsert(clonedPatternSegments, newFromStopControlPoint, movedStopControlPoint, newFromStopControlPoint.cpIndex) + } + // Insert the new to segment + if (!movedToEnd) { + clonedPatternSegments = await generateSegmentAndInsert(clonedPatternSegments, movedStopControlPoint, newToStopControlPoint, insertionPoint) + } + // $FlowFixMe + return clonedPatternSegments +} + +/** + * Updates the shapes (segments and control points) after the pattern has been reordered using the pattern stop card dragging feature. + * This method will override several of the segments around the old location of the moved stop, and around + * the new location where the stop is being moved to. + */ +export function updateShapesAfterPatternReorder (oldPattern: Pattern, newPatternStops: Array, oldPatternStopSequence: number) { + return async function (dispatch: dispatchFn, getState: getStateFn) { + const {controlPoints, patternSegments} = getControlPoints(getState()) + + // Use object copies of the arrays to preserve indices, we'll convert back after juggling deletions + let clonedControlPoints = clone(controlPoints) + let clonedPatternSegments = clone(patternSegments) + + const stopControlPoints = getControlPointsForStops(controlPoints) + + const newPatternStopSequence = newPatternStops.findIndex(pStop => pStop.stopSequence === oldPatternStopSequence) + + const movedFromStart = oldPatternStopSequence === 0 + const movedFromEnd = oldPatternStopSequence === newPatternStops.length - 1 + const movedToStart = newPatternStopSequence === 0 + const movedToEnd = newPatternStopSequence === newPatternStops.length - 1 + const movedForward = oldPatternStopSequence < newPatternStopSequence + const movedAdjacent = Math.abs(newPatternStopSequence - oldPatternStopSequence) + + const movedStopControlPoint: StopControlPoint = stopControlPoints[oldPatternStopSequence] + let deletedControlPoints: Array = [] + let deletedSegments: Array = [] + const previousFromStopControlPoint: StopControlPoint = stopControlPoints[oldPatternStopSequence - 1] // Null if moved from beginning + const previousToStopControlPoint: StopControlPoint = stopControlPoints[oldPatternStopSequence + 1] + // With the "new" segments in adjacent moves, we need to account for the from and to stops referencing themselves + const newFromStopControlPoint: StopControlPoint = stopControlPoints[movedAdjacent && movedForward ? newPatternStopSequence : newPatternStopSequence - 1] + const newToStopControlPoint: StopControlPoint = stopControlPoints[movedAdjacent && !movedForward ? newPatternStopSequence : newPatternStopSequence + 1]; // Semi-colon for babel parsing. + + // 1. Remove appropriate segments in the "old" position of the stop to move. + [deletedControlPoints, deletedSegments] = removeOldSegments( + previousFromStopControlPoint, + previousToStopControlPoint, + deletedSegments, + deletedControlPoints, + // $FlowFixMe + stopControlPoints, + movedFromStart, + movedFromEnd, + movedStopControlPoint, + oldPatternStopSequence + ); + + // 2. Remove appropriate segments in the "new" position of the moved stop (i.e. the destination) + [deletedControlPoints, deletedSegments] = removeNewSegments( + deletedControlPoints, + deletedSegments, + movedForward, + movedToEnd, + movedToStart, + newPatternStopSequence, + newToStopControlPoint, + // $FlowFixMe + stopControlPoints + ) + + // 3. Set our cloned sets of control points and segments to null to avoid juggling array indices. + // $FlowFixMe + deletedControlPoints.forEach(deletedCPIndex => { clonedControlPoints[deletedCPIndex] = null }) + // $FlowFixMe + deletedSegments.forEach(deletedSegmentIndex => { clonedPatternSegments[deletedSegmentIndex] = null }) + + // 4. Insert appropriate segments at the new and old locations + const insertionPoint = movedToStart ? 0 : newFromStopControlPoint.cpIndex + 1 + try { + // Check things are defined for flow + clonedPatternSegments = await insertSegments( + clonedPatternSegments, + insertionPoint, + movedFromEnd, + movedFromStart, + movedStopControlPoint, + movedToEnd, + movedToStart, + newFromStopControlPoint, + newToStopControlPoint, + previousFromStopControlPoint, + previousToStopControlPoint + ) + } catch (err) { + console.log(err) + // TODO: i18n this. + dispatch(setErrorMessage({message: `Could not rearrange stops in pattern: ${err}`})) + return + } + + // 5. Insert the stop into the new position and then convert to array + clonedControlPoints.splice(insertionPoint, 0, movedStopControlPoint) + // $FlowFixMe + if (movedForward) clonedControlPoints[movedStopControlPoint.cpIndex] = null + // $FlowFixMe + else clonedControlPoints[movedStopControlPoint.cpIndex + 1] = null // +1 to account for the spliced element added to the array. + + // 6. Remove any null entries from our array. + clonedControlPoints = clonedControlPoints.filter(el => !!el) + clonedPatternSegments = clonedPatternSegments.filter(el => !!el) // Cast type to allow filtering by flow. + + // 7. Update the control points with new shape_dist_traveled values + // $FlowFixMe + updateShapeDistTraveled(clonedControlPoints, clonedPatternSegments, newPatternStops) + + // 8. Update the pattern geometry and pattern stops to reflect changes. + dispatch(updatePatternGeometry({ + controlPoints: clonedControlPoints, + patternSegments: clonedPatternSegments + })) + + // Update pattern stops with cloned copy of cards (from the reorder). NOTE: stop resequencing + // is handled by updatePatternStops. + dispatch(updatePatternStops(oldPattern, newPatternStops)) + return dispatch(saveActiveGtfsEntity('trippattern')) + } +} diff --git a/lib/editor/actions/snapshots.js b/lib/editor/actions/snapshots.js index 70e695027..b7c45e7f0 100644 --- a/lib/editor/actions/snapshots.js +++ b/lib/editor/actions/snapshots.js @@ -5,7 +5,6 @@ import {createAction, type ActionType} from 'redux-actions' import {secureFetch} from '../../common/actions' import {getConfigProperty} from '../../common/util/config' import {handleJobResponse} from '../../manager/actions/status' - import type {Feed, Snapshot} from '../../types' import type {dispatchFn, getStateFn} from '../../types/reducers' @@ -87,9 +86,9 @@ export function downloadSnapshotViaCredentials (snapshot: Snapshot, isPublic: bo /** * Create a new snapshot from the data currently present in the editor buffer. */ -export function createSnapshot (feedSource: Feed, name: string, comment?: ?string, publishNewVersion?: boolean) { +export function createSnapshot (feedSource: Feed, name: string, comment?: ?string, publishNewVersion?: boolean, publishProprietaryFiles?: boolean) { return function (dispatch: dispatchFn, getState: getStateFn) { - const url = `/api/editor/secure/snapshot?feedId=${feedSource.id}${publishNewVersion ? '&publishNewVersion=true' : ''}` + const url = `/api/editor/secure/snapshot?feedId=${feedSource.id}${publishNewVersion ? '&publishNewVersion=true' : ''}${publishProprietaryFiles ? '&publishProprietaryFiles=true' : ''}` const snapshot = { feedId: feedSource.id, name, diff --git a/lib/editor/actions/trip.js b/lib/editor/actions/trip.js index ddef25d49..8576b463a 100644 --- a/lib/editor/actions/trip.js +++ b/lib/editor/actions/trip.js @@ -53,6 +53,7 @@ export const toggleAllRows = createAction( (payload: { active: boolean }) => payload ) export const toggleDepartureTimes = createVoidPayloadAction('TOGGLE_DEPARTURE_TIMES') +export const toggleUseSeconds = createVoidPayloadAction('TOGGLE_USE_SECONDS') export const toggleRowSelection = createAction( 'TOGGLE_SINGLE_TIMETABLE_ROW_SELECTION', (payload: { active: boolean, rowIndex: number }) => payload @@ -162,8 +163,8 @@ export function saveTripsForCalendar ( // 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 + continuous_pickup: null, + continuous_drop_off: null } tripCopy.stop_times = tripCopy.stop_times.map((stopTime, index) => { return {...defaults, ...(stopTime: any)} @@ -180,7 +181,10 @@ export function saveTripsForCalendar ( }) })) .then(trips => { - dispatch(fetchTripsForCalendar(feedId, pattern, calendarId, true)) + // if error in saving, state will not refresh so user does not lose work + if (!trips.includes(undefined)) { + dispatch(fetchTripsForCalendar(feedId, pattern, calendarId, true)) + } return errorIndexes }) } diff --git a/lib/editor/actions/tripPattern.js b/lib/editor/actions/tripPattern.js index 3234fd522..fbafa8b6d 100644 --- a/lib/editor/actions/tripPattern.js +++ b/lib/editor/actions/tripPattern.js @@ -4,21 +4,21 @@ import {createAction, type ActionType} from 'redux-actions' import {ActionCreators} from 'redux-undo' import {toast} from 'react-toastify' -import {resetActiveGtfsEntity, savedGtfsEntity, updateActiveGtfsEntity, updateEditSetting} from './active' import {createVoidPayloadAction, fetchGraphQL, secureFetch} from '../../common/actions' import {snakeCaseKeys} from '../../common/util/map-keys' import {generateUID} from '../../common/util/util' -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' import {entityIsNew} from '../util/objects' - import type {ControlPoint, Pattern, PatternStop} from '../../types' import type {dispatchFn, getStateFn} from '../../types/reducers' +import {fetchTripCounts} from './trip' +import {showEditorModal} from './editor' +import {resetActiveGtfsEntity, savedGtfsEntity, updateActiveGtfsEntity, updateEditSetting} from './active' + const fetchingTripPatterns = createVoidPayloadAction('FETCHING_TRIP_PATTERNS') const savedTripPattern = createVoidPayloadAction('SAVED_TRIP_PATTERN') export const setActivePatternSegment = createAction( @@ -51,12 +51,12 @@ export type EditorTripPatternActions = ActionType | * provides a way to bulk update existing trips when pattern stops are modified * (e.g., a pattern stop is inserted, removed, or its travel times modified). */ -export function normalizeStopTimes (patternId: number, beginStopSequence: number) { +export function normalizeStopTimes (patternId: number, beginStopSequence: number, interpolateStopTimes: boolean) { return function (dispatch: dispatchFn, getState: getStateFn) { const {data} = getState().editor const {feedSourceId} = data.active const sessionId = data.lock.sessionId || '' - const url = `/api/editor/secure/pattern/${patternId}/stop_times?feedId=${feedSourceId || ''}&sessionId=${sessionId}&stopSequence=${beginStopSequence}` + const url = `/api/editor/secure/pattern/${patternId}/stop_times?feedId=${feedSourceId || ''}&sessionId=${sessionId}&stopSequence=${beginStopSequence}&interpolateStopTimes=${interpolateStopTimes.toString()}` return dispatch(secureFetch(url, 'put')) .then(res => res.json()) .then(json => toast.info(`ⓘ ${json.updateResult}`, { diff --git a/lib/editor/components/CreateSnapshotModal.js b/lib/editor/components/CreateSnapshotModal.js index 2c74f2d25..9fa9688fc 100644 --- a/lib/editor/components/CreateSnapshotModal.js +++ b/lib/editor/components/CreateSnapshotModal.js @@ -8,7 +8,9 @@ import { ControlLabel, FormGroup, FormControl, - Modal + Modal, + OverlayTrigger, + Tooltip } from 'react-bootstrap' import {connect} from 'react-redux' @@ -34,6 +36,7 @@ type State = { loading: boolean, name: ?string, publishNewVersion: boolean, + publishProprietaryFiles: boolean, showModal: boolean, } @@ -42,6 +45,7 @@ function getDefaultState () { comment: null, name: formatTimestamp(), publishNewVersion: false, + publishProprietaryFiles: false, confirmPublishWithUnapproved: false, showModal: false, loading: false @@ -54,6 +58,12 @@ class CreateSnapshotModal extends Component { messages = getComponentMessages('CreateSnapshotModal') _onTogglePublish = (e: SyntheticInputEvent) => { + // If unchecking publishNewVersion, we need to also uncheck publishProprietaryFiles + if (!e.target.checked) this.setState({publishProprietaryFiles: false}) + this.setState({[e.target.name]: e.target.checked}) + } + + _onTogglePublishProprietaryFiles = (e: SyntheticInputEvent) => { this.setState({[e.target.name]: e.target.checked}) } @@ -91,9 +101,9 @@ class CreateSnapshotModal extends Component { ok = () => { const {createSnapshot, feedSource} = this.props - const {comment, name, publishNewVersion} = this.state + const {comment, name, publishNewVersion, publishProprietaryFiles} = this.state if (!name) return window.alert(this.messages('missingNameAlert')) - createSnapshot(feedSource, name, comment, publishNewVersion) + createSnapshot(feedSource, name, comment, publishNewVersion, publishProprietaryFiles) this.close() } @@ -105,6 +115,7 @@ class CreateSnapshotModal extends Component { loading, name, publishNewVersion, + publishProprietaryFiles, showModal } = this.state const {routes} = this.props @@ -142,13 +153,27 @@ class CreateSnapshotModal extends Component { placeholder={this.messages('fields.comment.placeholder')} /> - + {this.messages('fields.publishNewVersion.label')} + {publishNewVersion && + {this.messages('fields.publishProprietaryFiles.helpText')}}> + + + {this.messages('fields.publishProprietaryFiles.label')} + + + + } {loading && } {unapprovedRoutes.length > 0 && diff --git a/lib/editor/components/EditorFeedSourcePanel.js b/lib/editor/components/EditorFeedSourcePanel.js index 4677e702b..7c40ae23e 100644 --- a/lib/editor/components/EditorFeedSourcePanel.js +++ b/lib/editor/components/EditorFeedSourcePanel.js @@ -8,6 +8,7 @@ import moment from 'moment' import * as snapshotActions from '../actions/snapshots.js' import ConfirmModal from '../../common/components/ConfirmModal' +import ExportPatternsModal from '../../common/components/ExportPatternsModal.js' import {getComponentMessages, getConfigProperty} from '../../common/util/config' import CreateSnapshotModal from '../../editor/components/CreateSnapshotModal' import * as versionActions from '../../manager/actions/versions' @@ -63,20 +64,29 @@ export default class EditorFeedSourcePanel extends Component { ref='snapshotModal' /> + {feedSource.editorSnapshots && feedSource.editorSnapshots.length ?
{/* These are the available snapshots */} - Available snapshots}> - + + + {this.messages('availableSnapshots')} + + {snapshots.length === 0 - ? No other snapshots + ? {this.messages('noOtherSnapshots')} : snapshots.map(s => { return ( ) @@ -93,27 +103,25 @@ export default class EditorFeedSourcePanel extends Component { - - {this.messages('help.title')} - - }> -

{this.messages('help.body.0')}

-

{this.messages('help.body.1')}

+ + {this.messages('help.title')} + +

{this.messages('help.body.0')}

+

{this.messages('help.body.1')}

+
@@ -121,11 +129,12 @@ export default class EditorFeedSourcePanel extends Component { } } -type ItemProps = { +export type ItemProps = { createFeedVersionFromSnapshot: typeof versionActions.createFeedVersionFromSnapshot, deleteSnapshot: typeof snapshotActions.deleteSnapshot, disabled: boolean, downloadSnapshot: typeof snapshotActions.downloadSnapshot, + exportPatternsModal: any, feedSource: Feed, modal: any, restoreSnapshot: typeof snapshotActions.restoreSnapshot, @@ -141,8 +150,7 @@ class SnapshotItem extends Component { } _onClickExport = () => { - const {createFeedVersionFromSnapshot, feedSource, snapshot} = this.props - createFeedVersionFromSnapshot(feedSource, snapshot.id) + this.props.exportPatternsModal.open({snapshot: this.props.snapshot}) } _onDeleteSnapshot = () => { @@ -155,7 +163,7 @@ class SnapshotItem extends Component { } _onRestoreSnapshot = () => { - const {restoreSnapshot, feedSource, snapshot} = this.props + const {feedSource, restoreSnapshot, snapshot} = this.props this.props.modal.open({ title: `${this.messages('restore')}`, body: this.messages('confirmLoad'), @@ -172,14 +180,14 @@ class SnapshotItem extends Component { return (

+ title={snapshot.name}> {snapshot.name}

diff --git a/lib/editor/components/EditorHelpModal.js b/lib/editor/components/EditorHelpModal.js index b934c8d57..e08418434 100644 --- a/lib/editor/components/EditorHelpModal.js +++ b/lib/editor/components/EditorHelpModal.js @@ -7,7 +7,6 @@ import {LinkContainer} from 'react-router-bootstrap' import * as snapshotActions from '../actions/snapshots' import {getConfigProperty} from '../../common/util/config' - import type {Feed} from '../../types' import type {EditorStatus} from '../../types/reducers' @@ -113,7 +112,7 @@ export default class EditorHelpModal extends Component { block bsSize='large' data-test-id='import-latest-version-button' - disabled={!feedSource.latestVersionId || status.creatingSnapshot} + disabled={!feedSource.latestValidation || !feedSource.latestValidation.feedVersionId || status.creatingSnapshot} onClick={this._onClickLoad} > Import latest version diff --git a/lib/editor/components/EditorInput.js b/lib/editor/components/EditorInput.js index e1083b3a1..b72248c54 100644 --- a/lib/editor/components/EditorInput.js +++ b/lib/editor/components/EditorInput.js @@ -14,7 +14,8 @@ import {FIELD_PROPS} from '../util/types' import {doesNotExist} from '../util/validation' import TimezoneSelect from '../../common/components/TimezoneSelect' import LanguageSelect from '../../common/components/LanguageSelect' -import toSentenceCase from '../../common/util/to-sentence-case' +import {getComponentMessages} from '../../common/util/config' +import toSentenceCase from '../../common/util/text' import type {Entity, Feed, GtfsSpecField, GtfsAgency, GtfsStop} from '../../types' import type {EditorTables} from '../../types/reducers' @@ -55,6 +56,7 @@ const entityToOption = (entity: ?Entity, key: string) => { } export default class EditorInput extends React.Component { + messages = getComponentMessages('EditorInput') /** * Helper method for processing field value changes. */ @@ -193,12 +195,12 @@ export default class EditorInput extends React.Component { - Upload {activeComponent} branding asset + {this.messages('uploadAsset').replace('%activeComponent%', activeComponent)} -
Drop {activeComponent} image here, or click to select image to upload.
+
{this.messages('dropImage').replace('%activeComponent%', activeComponent)}
{url ? {field.name} { dateTimeProps.dateTime = currentValue ? +moment(currentValue) : defaultValue dateTimeProps.onChange = this._onDateChange if (!currentValue) { - dateTimeProps.defaultText = 'Please select a date' + dateTimeProps.defaultText = this.messages('selectDate') } return ( @@ -274,7 +276,7 @@ export default class EditorInput extends React.Component { {editorField === 'monday' // insert label for entire category on first checkbox - ?
Days of service
+ ?
{this.messages('daysOfService')}
: null }
@@ -304,7 +306,7 @@ export default class EditorInput extends React.Component { {/* Add field for empty string value if that is not an allowable option so that user selection triggers onChange */} {options.findIndex(option => option.value === '') === -1 && ( )} {field.options && field.options.map(o => ( @@ -345,7 +347,7 @@ export default class EditorInput extends React.Component { {basicLabel} { return ( , repeat: boolean, text: string -} +} & PathProps // props.leaflet is not used but destructured out so it's not passed to L.Polyline function getOptions ({ @@ -56,7 +57,7 @@ function getOptions ({ * with react-leaflet. I believe it might have been designed for react-leaflet v2, * but we're currently using v1. */ -export default class TextPath extends Path { +class TextPath extends Path { createLeafletElement (props: Props) { const { positions, text, ...pathOptions } = getOptions(props) const line = new L.Polyline(positions, OPTIONS) @@ -66,9 +67,15 @@ export default class TextPath extends Path { updateLeafletElement (fromProps: Props, toProps: Props) { const { positions, text, ...pathOptions } = getOptions(toProps) + // $FlowFixMe The new version of React-Leaflet brings strange types with it... this.leafletElement.setText(null) + // $FlowFixMe The new version of React-Leaflet brings strange types with it... this.leafletElement.setLatLngs(positions) + // $FlowFixMe The new version of React-Leaflet brings strange types with it... this.leafletElement.setStyle(OPTIONS) + // $FlowFixMe The new version of React-Leaflet brings strange types with it... this.leafletElement.setText(text, pathOptions) } } + +export default withLeaflet(TextPath) diff --git a/lib/editor/components/map/pattern-debug-lines.js b/lib/editor/components/map/pattern-debug-lines.js index aa17ccce2..57174603a 100644 --- a/lib/editor/components/map/pattern-debug-lines.js +++ b/lib/editor/components/map/pattern-debug-lines.js @@ -7,7 +7,6 @@ import lineString from 'turf-linestring' import {PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} from '../../constants' import {isValidStopControlPoint} from '../../util/map' - import type {ControlPoint, GtfsStop, Pattern} from '../../../types' import type {EditSettingsState} from '../../../types/reducers' @@ -20,6 +19,13 @@ type Props = { stops: Array } +export const getControlPointsForStops = (controlPoints: Array) => controlPoints + // Add control point indexes to determine if active segment is adjacent + // i.e., whether the line should be rendered. + .map((cp, index) => ({...cp, cpIndex: index})) + // Filter out the user-added anchors + .filter(isValidStopControlPoint) + /** * This react-leaflet component draws connecting lines between a pattern * geometry's anchor points (that are associated with stops) and their @@ -37,12 +43,7 @@ export default class PatternDebugLines extends PureComponent { if (!activePattern || !controlPoints || !stops) return null return (
- {controlPoints - // Add control point indexes to determine if activesegment is adjacent - // i.e., whether the line should be rendered. - .map((cp, index) => ({...cp, cpIndex: index})) - // Filter out the user-added anchors - .filter(isValidStopControlPoint) + {getControlPointsForStops(controlPoints) // The remaining number should match the number of stops .map((cp, index) => { const {cpIndex, point, stopId} = cp @@ -52,8 +53,8 @@ export default class PatternDebugLines extends PureComponent { return null } const patternStopIsActive = patternStop.index === index - // Do not render if some other pattern stop is active - if (typeof patternStop.index === 'number' && !patternStopIsActive) { + // Do not render if some other pattern stop is active or if we do not have point info (to make flow happy). + if ((typeof patternStop.index === 'number' && !patternStopIsActive) || (!point || !point.geometry || !point.geometry.coordinates)) { return null } const {coordinates: cpCoord} = point.geometry diff --git a/lib/editor/components/pattern/EditSettings.js b/lib/editor/components/pattern/EditSettings.js index 9e67e7a1d..fa9132c5c 100644 --- a/lib/editor/components/pattern/EditSettings.js +++ b/lib/editor/components/pattern/EditSettings.js @@ -6,7 +6,7 @@ import Rcslider from 'rc-slider' import {updateEditSetting} from '../../actions/active' import {CLICK_OPTIONS, SNAP_TO_OPTIONS} from '../../util' -import toSentenceCase from '../../../common/util/to-sentence-case' +import toSentenceCase from '../../../common/util/text' import type {EditSettingsState} from '../../../types/reducers' import type {GtfsRoute} from '../../../types' diff --git a/lib/editor/components/pattern/EditShapePanel.js b/lib/editor/components/pattern/EditShapePanel.js index 6965c9a8a..91bea36c0 100644 --- a/lib/editor/components/pattern/EditShapePanel.js +++ b/lib/editor/components/pattern/EditShapePanel.js @@ -151,6 +151,7 @@ export default class EditShapePanel extends Component { _getPatternStopsWithShapeIssues = () => { const {controlPoints, stops} = this.props return controlPoints + // $FlowFixMe: can't tell flow all elements are defined in the map. .filter(isValidStopControlPoint) .map((controlPoint, index) => { const {point, stopId} = controlPoint diff --git a/lib/editor/components/pattern/NormalizeStopTimesModal.js b/lib/editor/components/pattern/NormalizeStopTimesModal.js index 6847fb9f6..cb274a735 100644 --- a/lib/editor/components/pattern/NormalizeStopTimesModal.js +++ b/lib/editor/components/pattern/NormalizeStopTimesModal.js @@ -2,10 +2,11 @@ import Icon from '@conveyal/woonerf/components/icon' import React, { Component } from 'react' -import { Alert, Button, ControlLabel, FormControl, Modal } from 'react-bootstrap' +import { Alert, Button, Checkbox, ControlLabel, FormControl, Modal, OverlayTrigger, Tooltip } from 'react-bootstrap' import * as tripPatternActions from '../../actions/tripPattern' import type { GtfsStop, Pattern } from '../../../types' +import { getComponentMessages } from '../../../common/util/config' type Props = { activePattern: Pattern, @@ -15,17 +16,21 @@ type Props = { stops: Array } -type State = { patternStopIndex: number, show: boolean } +type State = { interpolateStopTimes: boolean, patternStopIndex: number, show: boolean } export default class NormalizeStopTimesModal extends Component { + messages = getComponentMessages('NormalizeStopTimesModal') + state = { + interpolateStopTimes: false, patternStopIndex: 0, // default to zeroth pattern stop show: false } _onClickNormalize = () => { const { activePattern, normalizeStopTimes } = this.props - normalizeStopTimes(activePattern.id, this.state.patternStopIndex) + normalizeStopTimes(activePattern.id, this.state.patternStopIndex, this.state.interpolateStopTimes) + this.setState({interpolateStopTimes: false}) } _onChangeStop = (evt: SyntheticInputEvent) => { @@ -33,17 +38,23 @@ export default class NormalizeStopTimesModal extends Component { } _onClose = () => { - this.setState({ show: false }) + this.setState({ show: false, interpolateStopTimes: false }) this.props.onClose() } + _onChangeInterpolation = () => { + this.setState({interpolateStopTimes: !this.state.interpolateStopTimes}) + } + render () { const { Body, Footer, Header, Title } = Modal const { activePattern, stops } = this.props + const timepoints = activePattern.patternStops.filter(ps => ps.timepoint === 1) + const interpolationDisabled = timepoints.length < 2 return (
- Normalize stop times? + {this.messages('normalizeStopTimesQuestion')}

@@ -51,7 +62,7 @@ export default class NormalizeStopTimesModal extends Component { times for all trips on this pattern to conform to the default travel and dwell times defined for the pattern stops.

- Select beginning pattern stop: + {this.messages('selectBeginningPatternStop')} { } )} +
+ {this.messages('tooFewTimepoints')}} + placement='bottom' + // Semi-hack: Use the trigger prop to conditionally render the tooltip text only when checkbox is disabled. + trigger={interpolationDisabled ? ['hover'] : []} + > + + + {/* Separate label so that tooltip appears over checkbox. Hack: Padding to align center with checkbox */} + {this.messages('interpolateStopTimes')} +

{this.state.patternStopIndex === 0 @@ -86,15 +113,11 @@ export default class NormalizeStopTimesModal extends Component { } -
Usage notes
+
{this.messages('usageNotes')}
- This feature is useful when the travel times for one or more - pattern stops change. Take for example a pattern - that has been re-routed along to travel a longer distance, has had a - stop added (or removed), or has had a layover introduced mid-trip. - Once you have adjusted the travel times to account for these changes, - you can normalize the stop times to bring them into alignment with the - updated travel times reflected in the pattern stops. + {this.messages('usageExplanationOne')} +
+ {this.messages('usageExplanationTwo')}
Note: this does not account for any variation in travel time between stops for trips throughout the day (e.g., @@ -108,11 +131,11 @@ export default class NormalizeStopTimesModal extends Component { bsStyle='primary' onClick={this._onClickNormalize} > - Normalize stop times + {this.messages('normalizeStopTimes')}
diff --git a/lib/editor/components/pattern/PatternStopContainer.js b/lib/editor/components/pattern/PatternStopContainer.js index 2f904c705..f414780a6 100644 --- a/lib/editor/components/pattern/PatternStopContainer.js +++ b/lib/editor/components/pattern/PatternStopContainer.js @@ -1,7 +1,7 @@ // @flow import React, {Component} from 'react' -import update from 'react/lib/update' +import update from 'react-addons-update' import { shallowEqual } from 'react-pure-render' import { DropTarget, DragDropContext } from 'react-dnd' import HTML5Backend from 'react-dnd-html5-backend' @@ -9,10 +9,10 @@ import HTML5Backend from 'react-dnd-html5-backend' import * as activeActions from '../../actions/active' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' -import PatternStopCard from './PatternStopCard' - import type {GtfsStop, Feed, Pattern, PatternStop} from '../../../types' +import PatternStopCard from './PatternStopCard' + type Props = { activePattern: Pattern, addStopToPattern: typeof stopStrategiesActions.addStopToPattern, @@ -26,11 +26,13 @@ type Props = { status: any, stops: Array, updateActiveGtfsEntity: typeof activeActions.updateActiveGtfsEntity, - updatePatternStops: typeof tripPatternActions.updatePatternStops + updatePatternStops: typeof tripPatternActions.updatePatternStops, + updateShapesAfterPatternReorder: typeof stopStrategiesActions.updateShapesAfterPatternReorder } type State = { - cards: Array + cards: Array, + updatedPatternStopSequence: number } const cardTarget = { @@ -62,29 +64,11 @@ class PatternStopContainer extends Component { dropCard = () => { const { activePattern, - saveActiveGtfsEntity, - updatePatternStops + updateShapesAfterPatternReorder } = this.props - // FIXME: Move around control points based on pattern stop reorder? Simply - // changing the stop IDs is not sufficient (the shape dist traveled probably) - // out to change too. However, this may not be necessary. - // let stopIndex = 0 - // updatePatternGeometry({ - // // Reverse control points - // controlPoints: clone(controlPoints).map((cp, i) => { - // if (cp.pointType === POINT_TYPE.STOP) { - // // Update stopId based on new pattern stop order - // cp.stopId = patternStops[stopIndex++].stopId - // } - // return cp - // }), - // // Reverse order of segments and each segment's coordinate list. - // patternSegments - // }) - // Update pattern stops with cloned copy of cards. NOTE: stop resequencing - // is handled by updatePatternStops. - updatePatternStops(activePattern, [...this.state.cards]) - saveActiveGtfsEntity('trippattern') + // Update the modified pattern segments and update the control points with appropriate shape_dist_traveled values. + // This method also saves the trip pattern to preserve changes + updateShapesAfterPatternReorder(activePattern, [...this.state.cards], this.state.updatedPatternStopSequence) } moveCard = (id, atIndex) => { @@ -95,7 +79,8 @@ class PatternStopContainer extends Component { [index, 1], [atIndex, 0, card] ] - } + }, + updatedPatternStopSequence: {$set: card.stopSequence} })) } @@ -111,20 +96,20 @@ class PatternStopContainer extends Component { render () { const { activePattern, + addStopToPattern, // $FlowFixMe connectDropTarget, - patternStop, - saveActiveGtfsEntity, feedSource, patternEdited, + patternStop, + removeStopFromPattern, + saveActiveGtfsEntity, + setActiveEntity, setActiveStop, status, stops, updateActiveGtfsEntity, - updatePatternStops, - addStopToPattern, - removeStopFromPattern, - setActiveEntity + updatePatternStops } = this.props const { cards } = this.state if (!stops) return null @@ -142,27 +127,27 @@ class PatternStopContainer extends Component { return ( // $FlowFixMe + setActiveStop={setActiveStop} + status={status} + stop={stop} + updateActiveGtfsEntity={updateActiveGtfsEntity} // fallback to index if/when id changes + updatePatternStops={updatePatternStops} /> ) })}
diff --git a/lib/editor/components/pattern/PatternStopsPanel.js b/lib/editor/components/pattern/PatternStopsPanel.js index 1192ecda8..e85ecbf2a 100644 --- a/lib/editor/components/pattern/PatternStopsPanel.js +++ b/lib/editor/components/pattern/PatternStopsPanel.js @@ -8,15 +8,15 @@ import * as activeActions from '../../actions/active' import * as mapActions from '../../actions/map' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' -import AddPatternStopDropdown from './AddPatternStopDropdown' -import NormalizeStopTimesModal from './NormalizeStopTimesModal' -import PatternStopContainer from './PatternStopContainer' import VirtualizedEntitySelect from '../VirtualizedEntitySelect' import {getEntityBounds, getEntityName} from '../../util/gtfs' - import type {Pattern, GtfsStop, Feed, ControlPoint, Coordinates} from '../../../types' import type {EditorStatus, EditSettingsUndoState, MapState} from '../../../types/reducers' +import PatternStopContainer from './PatternStopContainer' +import NormalizeStopTimesModal from './NormalizeStopTimesModal' +import AddPatternStopDropdown from './AddPatternStopDropdown' + type Props = { activePattern: Pattern, addStopToPattern: typeof stopStrategiesActions.addStopToPattern, @@ -38,7 +38,8 @@ type Props = { updateEditSetting: typeof activeActions.updateEditSetting, updateMapSetting: typeof mapActions.updateMapSetting, updatePatternGeometry: typeof mapActions.updatePatternGeometry, - updatePatternStops: typeof tripPatternActions.updatePatternStops + updatePatternStops: typeof tripPatternActions.updatePatternStops, + updateShapesAfterPatternReorder: typeof stopStrategiesActions.updateShapesAfterPatternReorder } type State = { @@ -98,7 +99,8 @@ export default class PatternStopsPanel extends Component { status, stops, updateActiveGtfsEntity, - updatePatternStops + updatePatternStops, + updateShapesAfterPatternReorder } = this.props const {addStops} = editSettings.present const {patternStopCandidate} = this.state @@ -120,11 +122,14 @@ export default class PatternStopsPanel extends Component {
@@ -167,6 +172,7 @@ export default class PatternStopsPanel extends Component { patternSegments={patternSegments} updatePatternStops={updatePatternStops} updatePatternGeometry={updatePatternGeometry} + updateShapesAfterPatternReorder={updateShapesAfterPatternReorder} status={status} updateActiveGtfsEntity={updateActiveGtfsEntity} saveActiveGtfsEntity={saveActiveGtfsEntity} diff --git a/lib/editor/components/pattern/TripPatternList.js b/lib/editor/components/pattern/TripPatternList.js index 52285ca90..f6bea4456 100644 --- a/lib/editor/components/pattern/TripPatternList.js +++ b/lib/editor/components/pattern/TripPatternList.js @@ -11,9 +11,6 @@ import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' import Loading from '../../../common/components/Loading' import * as statusActions from '../../../manager/actions/status' -import TripPatternViewer from './TripPatternViewer' -import TripPatternListControls from './TripPatternListControls' - import type {Props as ContainerProps} from '../../containers/ActiveTripPatternList' import type { ControlPoint, @@ -29,6 +26,9 @@ import type { MapState } from '../../../types/reducers' +import TripPatternListControls from './TripPatternListControls' +import TripPatternViewer from './TripPatternViewer' + export type Props = ContainerProps & { activeEntity: GtfsRoute, activePattern: Pattern, @@ -63,7 +63,8 @@ export type Props = ContainerProps & { updateEditSetting: typeof activeActions.updateEditSetting, updateMapSetting: typeof mapActions.updateMapSetting, updatePatternGeometry: typeof mapActions.updatePatternGeometry, - updatePatternStops: typeof tripPatternActions.updatePatternStops + updatePatternStops: typeof tripPatternActions.updatePatternStops, + updateShapesAfterPatternReorder: typeof stopStrategiesActions.updateShapesAfterPatternReorder } export default class TripPatternList extends Component { diff --git a/lib/editor/components/timetable/CalendarSelect.js b/lib/editor/components/timetable/CalendarSelect.js index c2cb872cc..200f8ce7f 100644 --- a/lib/editor/components/timetable/CalendarSelect.js +++ b/lib/editor/components/timetable/CalendarSelect.js @@ -7,8 +7,8 @@ import Select from 'react-select' import * as activeActions from '../../actions/active' import {entityIsNew} from '../../util/objects' - -import type {Pattern, GtfsRoute, Feed, ServiceCalendar, Trip, TripCounts} from '../../../types' +import {getComponentMessages} from '../../../common/util/config' +import type {Pattern, GtfsRoute, Feed, ServiceCalendar, ScheduleExceptionCalendar, Trip, TripCounts} from '../../../types' type CalendarOption = { calendar: ServiceCalendar, @@ -21,9 +21,10 @@ type CalendarOption = { } type Props = { - activeCalendar: ?ServiceCalendar, + activeCalendar: ?ServiceCalendar | ?ScheduleExceptionCalendar, activePattern: Pattern, calendars: Array, + exceptionBasedCalendars: ?Array, feedSource: Feed, route: GtfsRoute, setActiveEntity: typeof activeActions.setActiveEntity, @@ -32,6 +33,7 @@ type Props = { } export default class CalendarSelect extends Component { + messages = getComponentMessages('CalendarSelect') _optionRenderer = (option: CalendarOption) => { const { label, @@ -49,28 +51,36 @@ export default class CalendarSelect extends Component { {label} {' '} - - {patternTrips} - - {/** {' '} - - {routeCount} - **/} - {' '} - - {totalTrips} - + {/* $FlowFixMe: need to add two types of options */} + {option.type !== 'exception-based' + ? <> + + {patternTrips} + + {/** {' '} + + {routeCount} + **/} + {' '} + + {totalTrips} + + + : <> + {this.messages('exceptionBasedCalendar')} + + } ) } _onChange = (value: CalendarOption) => { const {activePattern, feedSource, route, setActiveEntity} = this.props - const calendar = value && value.calendar + const calendar = value && (value.calendar || value) setActiveEntity( feedSource.id, 'route', @@ -94,9 +104,9 @@ export default class CalendarSelect extends Component { } _getOptions = (): Array => { - const {activePattern, calendars, tripCounts, trips} = this.props + const {activePattern, calendars, exceptionBasedCalendars, tripCounts, trips} = this.props const patternId = activePattern && activePattern.patternId - const calendarOptions: Array = calendars && activePattern + const calendarBasedOptions: Array = calendars && activePattern ? calendars .map(calendar => ({ label: calendar.description || calendar.service_id, @@ -111,6 +121,18 @@ export default class CalendarSelect extends Component { })) : [] + // Add exception based calendars to the list + // TODO: trip counts (total v pattern) + const exceptionBasedCalendarOptions = exceptionBasedCalendars && exceptionBasedCalendars.map(exception => ({ + label: exception.name || exception.id, + value: exception.service_id, + type: 'exception-based', + service_id: exception.service_id // For an exception based schedule the custom schedule should only have one entry. + })) + + // $FlowFixMe: we need two types of options + const calendarOptions = calendarBasedOptions.concat(exceptionBasedCalendarOptions) + return calendarOptions .sort((a, b) => { return b.patternTrips - a.patternTrips @@ -128,7 +150,7 @@ export default class CalendarSelect extends Component { return ( { onChange={this._onLabelModeSelectorClick} style={{margin: '0 3px'}} > - - + + - of the labels: + {this.messages('ofTheLabels')}
{project.labels.map((label) => ( @@ -371,9 +371,9 @@ export default class ProjectFeedListToolbar extends PureComponent { return ( - Filter feed sources on + { this.messages('filterFeedSources')} {strategySelect} - version + { this.messages('version')} ) } @@ -410,7 +410,7 @@ export default class ProjectFeedListToolbar extends PureComponent { bsSize='small' id='project-feedsource-table-sort-button' style={{ marginLeft: 20 }} - title='Sort By' + title={this.messages('sortBy')} > {this._renderSortOptions()} @@ -439,7 +439,7 @@ export default class ProjectFeedListToolbar extends PureComponent { bsSize='small' id='project-feedsource-label-filter-button' title={ - + {' '} {activeFilterLabelCount} @@ -458,7 +458,7 @@ export default class ProjectFeedListToolbar extends PureComponent { data-test-id='project-header-action-dropdown-button' id='project-header-actions' style={{ marginLeft: 20 }} - title='Actions' + title={this.messages('actions')} > {!projectEditDisabled && ( { - messages = getComponentMessages('ProjectSettings') - _updateProjectSettings = (project: Project, settings: Object) => { - const {updateProject} = this.props + const { updateProject } = this.props // Update project and re-fetch feeds. updateProject(project.id, settings, true) } render () { const { - activeSettingsPanel, deleteProject, project, projectEditDisabled, updateProject, user } = this.props - const activePanel = !activeSettingsPanel - ? - : - return ( - - - - - - - {this.messages('title')} - - - {isModuleEnabled('deployment') - ? ( - - {this.messages('deployment.title')} - - ) - : null - } - - - - - {activePanel} - - - ) + return + + + + + } } diff --git a/lib/manager/components/ProjectSettingsForm.js b/lib/manager/components/ProjectSettingsForm.js index b5794e97e..177983a06 100644 --- a/lib/manager/components/ProjectSettingsForm.js +++ b/lib/manager/components/ProjectSettingsForm.js @@ -20,12 +20,10 @@ import { Row } from 'react-bootstrap' import DateTimeField from 'react-bootstrap-datetimepicker' -import ReactDOM from 'react-dom' import { shallowEqual } from 'react-pure-render' import { browserHistory } from 'react-router' import * as projectsActions from '../actions/projects' -import MapModal from '../../common/components/MapModal.js' import ConfirmModal from '../../common/components/ConfirmModal' import TimezoneSelect from '../../common/components/TimezoneSelect' import { getComponentMessages } from '../../common/util/config' @@ -33,6 +31,8 @@ import { parseBounds, validationState } from '../util' import type { Bounds, Project } from '../../types' import type { ManagerUserState } from '../../types/reducers' +import CustomCSVForm from './transform/CustomCSVForm' + type ProjectModel = { autoFetchFeeds?: boolean, autoFetchHour?: number, @@ -41,7 +41,8 @@ type ProjectModel = { defaultTimeZone?: string, id?: string, name?: string, - peliasWebhookUrl?: string + peliasWebhookUrl?: string, + sharedStopsConfig?: string } type Props = { @@ -135,7 +136,7 @@ export default class ProjectSettingsForm extends Component { const autoFetchFeeds = evt.target.checked this.setState(update(this.state, { model: { - $merge: {autoFetchFeeds, autoFetchMinute, autoFetchHour} + $merge: {autoFetchFeeds, autoFetchHour, autoFetchMinute} } })) } @@ -173,8 +174,8 @@ export default class ProjectSettingsForm extends Component { this.setState(update(this.state, { model: { $merge: { - autoFetchMinute: time.minutes(), - autoFetchHour: time.hours() + autoFetchHour: time.hours(), + autoFetchMinute: time.minutes() } } })) @@ -184,8 +185,12 @@ export default class ProjectSettingsForm extends Component { this.setState(update(this.state, {model: {$merge: {defaultTimeZone}}})) } - _onChangeTextInput = ({target}: {target: HTMLInputElement}) => { + // TODO: shared type + // https://github.com/ibi-group/datatools-ui/pull/986#discussion_r1362271761 + _onChangeTextInput = ({target}: {target: {name?: string, value: string}}) => { const {name, value} = target + if (!name) return + this.setState( update( this.state, @@ -197,39 +202,6 @@ export default class ProjectSettingsForm extends Component { ) } - _onOpenMapBoundsModal = () => { - const project = this.props - // $FlowFixMe - ignore type check to make flow happy - const bounds = project.bounds - ? [ - [project.bounds.south, project.bounds.west], - [project.bounds.north, project.bounds.east] - ] - : null - this.refs.mapModal.open({ - title: 'Select project bounds', - body: `Pretend this is a map`, - bounds: bounds, - rectangleSelect: true, - onConfirm: (rectangle) => { - if (rectangle && rectangle.getBounds()) { - const [[south, west], [north, east]] = rectangle.getBounds() - .map(arr => arr.map(v => v.toFixed(6))) - const domNode: HTMLInputElement = ( - (ReactDOM.findDOMNode(this.refs.boundingBox): any): HTMLInputElement - ) - if (domNode) { - domNode.value = `${west},${south},${east},${north}` - } - this.setState(update(this.state, { - model: { $merge: {west, south, east, north} } - })) - } - return rectangle - } - }) - } - _onSaveSettings = () => { const {project, updateProject} = this.props // Prevent a save if there have been no edits or form is invalid @@ -258,6 +230,7 @@ export default class ProjectSettingsForm extends Component { return Object.keys(validation).every(k => validation[k]) } + // eslint-disable-next-line complexity render () { const {editDisabled, showDangerZone} = this.props const {model, validation} = this.state @@ -266,34 +239,37 @@ export default class ProjectSettingsForm extends Component { if (editDisabled) { return (

- Warning!{' '} - You do not have permission to edit details for this feed source. + {this.messages('warning')}{' '} + {this.messages('noPermissions')}

) } + return (
- {this.messages('title')}}> - + + {this.messages('title')} + {this.messages('fields.name')} - Required. + {this.messages('required')} - {this.messages('fields.updates.title')}}> - + + {this.messages('fields.updates.title')} + { mode='time' onChange={this._onChangeDateTime} /> - : null} + : ''} - {this.messages('fields.location.title')}}> - + + {this.messages('fields.location.title')} + @@ -330,24 +307,16 @@ export default class ProjectSettingsForm extends Component { - { - {/* 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. */} - - } @@ -357,21 +326,43 @@ export default class ProjectSettingsForm extends Component { {this.messages('fields.location.defaultTimeZone')} - Local Places Index}> - + + {this.messages('fields.sharedStops.title')} + + + + {/* TODO: on enter, textarea should NOT submit. This causes strange behavior when + editing in the textarea + + see: https://github.com/ibi-group/datatools-ui/pull/977#discussion_r1288916749 */} + {}} + placeholder={`stop_group_id,feed_id,stop_id,is_primary\n1,1,29240,1\n1,3,4705,0`} + /> + + + + + + {this.messages('fields.localPlacesIndex.title')} + - Webhook URL + {this.messages('fields.localPlacesIndex.webhookUrl')} @@ -379,8 +370,9 @@ export default class ProjectSettingsForm extends Component { {showDangerZone && - Danger zone}> - + + {this.messages('dangerZone')} + -

Delete this project.

-

Once you delete an project, the project and all feed sources it contains cannot be recovered.

+

{this.messages('deleteThisProject')}

+

{this.messages('deleteWarning')}

@@ -413,7 +405,6 @@ export default class ProjectSettingsForm extends Component { -
) } diff --git a/lib/manager/components/ProjectViewer.js b/lib/manager/components/ProjectViewer.js index b99941c1e..95705a820 100644 --- a/lib/manager/components/ProjectViewer.js +++ b/lib/manager/components/ProjectViewer.js @@ -72,7 +72,7 @@ export default class ProjectViewer extends Component { } componentWillMount () { - const {projectId, onProjectViewerMount} = this.props + const {onProjectViewerMount, projectId} = this.props onProjectViewerMount(projectId) } @@ -80,8 +80,8 @@ export default class ProjectViewer extends Component { const {activeComponent, activeSubComponent, project} = this.props return isModuleEnabled('deployment') && ( {this.messages('deployments')} @@ -103,16 +103,17 @@ export default class ProjectViewer extends Component { return isModuleEnabled('enterprise') && !this._isProjectEditDisabled() &&
{s3Bucket &&

- Note: Public feeds page can be viewed{' '} - here. + {this.messages('note')}{' '} + {this.messages('publicViewed')} + {this.messages('here')}.

}
@@ -137,7 +138,6 @@ export default class ProjectViewer extends Component { render () { const { activeComponent, - activeSubComponent, createFeedSource, isFetching, project, @@ -165,9 +165,9 @@ export default class ProjectViewer extends Component {

- No project found for {this.props.projectId} + {this.messages('noProjectFound')} {this.props.projectId}

-

Return to list of projects

+

{this.messages('returnToProjects')}

@@ -186,9 +186,9 @@ export default class ProjectViewer extends Component { {getConfigProperty('application.notifications_enabled') ? : null} @@ -204,14 +204,14 @@ export default class ProjectViewer extends Component { ? '0' + project.autoFetchMinute : project.autoFetchMinute }` - : 'Auto fetch disabled'} + : this.messages('autoFetchDisabled')} @@ -227,7 +227,7 @@ export default class ProjectViewer extends Component { } > - + {this.state.createMode ? ( { /> )} - + {this._renderPublicFeeds()} @@ -251,8 +251,8 @@ export default class ProjectViewer extends Component {
{this._renderDeploymentsTab()} @@ -264,7 +264,6 @@ export default class ProjectViewer extends Component { // keyboard listener is not active while form is not visible. activeComponent === 'settings' && ( @@ -278,8 +277,10 @@ export default class ProjectViewer extends Component { } class ProjectSummaryPanel extends Component<{feedSources: Array, project: Project}> { + messages = getComponentMessages('ProjectSummaryPanel') + render () { - const { project, feedSources } = this.props + const { feedSources, project } = this.props const errorCount = feedSources .map(fs => fs.latestValidation ? fs.latestValidation.errorCount : 0) .reduce((a, b) => a + b, 0) @@ -288,14 +289,15 @@ class ProjectSummaryPanel extends Component<{feedSources: Array, project: .map(fs => fs.latestValidation ? fs.latestValidation.avgDailyRevenueTime : 0) .reduce((a, b) => a + b, 0) return ( - {project.name} summary}> - - Number of feeds: {feedSources.length} - Total errors: {errorCount} + + {project.name} {this.messages('summary')} + + {this.messages('numberOfFeeds')} {feedSources.length} + {this.messages('totalErrors')} {errorCount} - Total service:{' '} + {this.messages('totalService')}{' '} {Math.floor(serviceSeconds / 60 / 60 * 100) / 100}{' '} - hours per weekday + {this.messages('hoursPerWeekday')} @@ -304,13 +306,16 @@ class ProjectSummaryPanel extends Component<{feedSources: Array, project: } const ExplanatoryPanel = ({ project }) => { - // If user has more than 3 labels, hide the feed source instruction + const messages = getComponentMessages('ProjectViewer') + + // If project 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. + + {messages('feedSourceTitle')} + + {messages('feedSourceDesc')} + ) } diff --git a/lib/manager/components/ProjectsList.js b/lib/manager/components/ProjectsList.js index 76085b03a..efa83d003 100644 --- a/lib/manager/components/ProjectsList.js +++ b/lib/manager/components/ProjectsList.js @@ -21,7 +21,6 @@ import ManagerPage from '../../common/components/ManagerPage' import EditableTextField from '../../common/components/EditableTextField' import {getComponentMessages} from '../../common/util/config' import {defaultSorter} from '../../common/util/util' - import type {Props as ContainerProps} from '../containers/ActiveProjectsList' import type {Project} from '../../types' import type {ManagerUserState} from '../../types/reducers' @@ -82,76 +81,79 @@ export default class ProjectsList extends Component { ref='page' title={this.messages('title')}> - Projects)}> - - - - - - - - {this.messages('help.content')} - - }> + + Projects + + + + + + - - - - - - - - - - - - - {visibleProjects.length > 0 - ? visibleProjects.map((project) => ( - - )) - : - - + + {this.messages('help.content')} + } - -
- {this.messages('table.name')} - -
- {this.messages('noProjects')} - {' '} - -
- -
+ placement='left' + trigger='click'> + + + + + + + + + + + + + + {visibleProjects.length > 0 + ? visibleProjects.map((project) => ( + + )) + : + + + } + +
+ {this.messages('table.name')} + +
+ {this.messages('noProjects')} + {' '} + +
+ +
+
@@ -181,10 +183,10 @@ class ProjectRow extends Component<{
+ value={project.name} />
diff --git a/lib/manager/components/TransformationsViewer.js b/lib/manager/components/TransformationsViewer.js index 87a5bb2c2..d0e5d331e 100644 --- a/lib/manager/components/TransformationsViewer.js +++ b/lib/manager/components/TransformationsViewer.js @@ -4,6 +4,7 @@ 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 { getComponentMessages } from '../../common/util/config' import type { FeedVersion, TableTransformResult } from '../../types' type Props = { @@ -11,16 +12,17 @@ type Props = { } export default class TransformationsViewer extends Component { + messages = getComponentMessages('TransformationsViewer') _getBadge (transformResult: TableTransformResult) { switch (transformResult.transformType) { case 'TABLE_MODIFIED': - return Table Modified + return {this.messages('tableModified')} case 'TABLE_ADDED': - return Table Added + return {this.messages('tableAdded')} case 'TABLE_REPLACED': - return Table Replaced + return {this.messages('tableReplaced')} case 'TABLE_DELETED': - return Table Deleted + return {this.messages('tableDeleted')} } } @@ -34,25 +36,28 @@ export default class TransformationsViewer extends Component { 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} + + {/* Use toString on numbers to make flow happy */} + {this.messages('rowsAdded').replace('%rows%', res.addedCount.toString())} + {this.messages('rowsDeleted').replace('%rows%', res.deletedCount.toString())} + {this.messages('rowsUpdated').replace('%rows%', res.updatedCount.toString())} + {this.messages('columnsAdded').replace('%columns%', res.customColumnsAdded.toString())}
) }) return ( - Transformations}> + + {this.messages('transformationsTitle')} {transformContent} ) } else { - return

No transformations applied.

+ return

{this.messages('noTransformationApplied')}

} } } diff --git a/lib/manager/components/UserAccountInfoPanel.js b/lib/manager/components/UserAccountInfoPanel.js index 2ccd1bc04..c14b49ab5 100644 --- a/lib/manager/components/UserAccountInfoPanel.js +++ b/lib/manager/components/UserAccountInfoPanel.js @@ -4,8 +4,8 @@ import React, {Component} from 'react' import { Panel, Badge, Row, Col } from 'react-bootstrap' import * as userActions from '../actions/user' +import {getComponentMessages} from '../../common/util/config' import UserButtons from '../../common/components/UserButtons' - import type {ManagerUserState} from '../../types/reducers' type Props = { @@ -14,6 +14,8 @@ type Props = { } export default class UserAccountInfoPanel extends Component { + messages = getComponentMessages('UserAccountInfoPanel') + render () { const { user, @@ -26,42 +28,44 @@ export default class UserAccountInfoPanel extends Component { } return ( - - - Profile - - -

- Hello, {profile.nickname}. -

-
{profile.email}
-
- - {permissions.isApplicationAdmin() - ? 'Application admin' - : permissions.canAdministerAnOrganization() - ? 'Organization admin' - : 'Standard user' - } - - {/* TODO: fetch organization for user and show badge here */} - {' '} - {/* userOrganization && + + + + {this.messages('profile')} + + +

+ {this.messages('hello')} {profile.nickname}. +

+
{profile.email}
+
+ + {permissions.isApplicationAdmin() + ? this.messages('roles.applicationAdmin') + : permissions.canAdministerAnOrganization() + ? this.messages('roles.organizationAdmin') + : this.messages('roles.standardUser') + } + + {/* TODO: fetch organization for user and show badge here */} + {' '} + {/* userOrganization && user.permissions.getOrganizationId() */} -
- -
- - - - - +
+ +
+ + + + + +
) } diff --git a/lib/manager/components/UserHomePage.js b/lib/manager/components/UserHomePage.js index 2086c9083..58828e753 100644 --- a/lib/manager/components/UserHomePage.js +++ b/lib/manager/components/UserHomePage.js @@ -1,27 +1,29 @@ // @flow +import { Auth0ContextInterface } from '@auth0/auth0-react' import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' -import {Grid, Row, Col, Button, ButtonToolbar, Jumbotron} from 'react-bootstrap' +import { Alert, Button, ButtonToolbar, Col, Grid, Jumbotron, Row } from 'react-bootstrap' import objectPath from 'object-path' import * as feedsActions from '../actions/feeds' import * as userActions from '../actions/user' import * as visibilityFilterActions from '../actions/visibilityFilter' import ManagerPage from '../../common/components/ManagerPage' -import { DEFAULT_DESCRIPTION, DEFAULT_TITLE } from '../../common/constants' -import {getConfigProperty} from '../../common/util/config' +import { AUTH0_DISABLED, DEFAULT_DESCRIPTION } from '../../common/constants' +import {getConfigProperty, getComponentMessages, getAppName} from '../../common/util/config' import {defaultSorter} from '../../common/util/util' +import type {Props as ContainerProps} from '../containers/ActiveUserHomePage' +import type {Project} from '../../types' +import type {ManagerUserState, ProjectFilter} from '../../types/reducers' + import RecentActivityBlock from './RecentActivityBlock' import UserAccountInfoPanel from './UserAccountInfoPanel' import FeedSourcePanel from './FeedSourcePanel' import HomeProjectDropdown from './HomeProjectDropdown' -import type {Props as ContainerProps} from '../containers/ActiveUserHomePage' -import type {Project} from '../../types' -import type {ManagerUserState, ProjectFilter} from '../../types/reducers' - type Props = ContainerProps & { + auth0: Auth0ContextInterface, fetchProjectFeeds: typeof feedsActions.fetchProjectFeeds, logout: typeof userActions.logout, onUserHomeMount: typeof userActions.onUserHomeMount, @@ -49,6 +51,8 @@ export default class UserHomePage extends Component { showLoading: false } + messages = getComponentMessages('UserHomePage') + componentWillMount () { const {onUserHomeMount, projectId, user} = this.props onUserHomeMount(user, projectId) @@ -66,65 +70,80 @@ export default class UserHomePage extends Component { this.setState({showLoading: true}) } + handleLogout = () => { + this.props.logout(this.props.auth0) + } + render () { const { projects, project, - user, - logout, - visibilityFilter, + setVisibilityFilter, setVisibilitySearchText, - setVisibilityFilter + user, + visibilityFilter } = this.props const visibleProjects = projects.sort(defaultSorter) const activeProject = project + const appTitle = getAppName() return ( + > {this.state.showLoading ? : null} {/* Top Welcome Box */} -

Welcome to {getConfigProperty('application.title') || DEFAULT_TITLE}!

+

{this.messages('welcomeTo')} {appTitle}!

{getConfigProperty('application.description') || DEFAULT_DESCRIPTION}

+ {/* Info banner shown if auth is disabled. */} + {AUTH0_DISABLED && ( + + + {this.messages('authDisabledInfo').replace('%appTitle%', appTitle)} + + )} {/* Recent Activity List */} -

- Recent Activity +

+ {this.messages('recentActivity')}

{user.recentActivity && user.recentActivity.length ? user.recentActivity.sort(sortByDate).map((item, k) => { - return + return }) - : No Recent Activity for your subscriptions. + : {this.messages('recentActivityNone')} } + /> + visibleProjects={visibleProjects} + /> + setVisibilitySearchText={setVisibilitySearchText} + visibilityFilter={visibilityFilter} + user={user} + /> diff --git a/lib/manager/components/deployment/CurrentDeploymentPanel.js b/lib/manager/components/deployment/CurrentDeploymentPanel.js index 3f4f9bd47..efd5088d9 100644 --- a/lib/manager/components/deployment/CurrentDeploymentPanel.js +++ b/lib/manager/components/deployment/CurrentDeploymentPanel.js @@ -12,9 +12,7 @@ import { 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 type { Deployment, DeploySummary, @@ -24,6 +22,8 @@ import type { ServerJob } from '../../../types' +import DeploymentPreviewButton from './DeploymentPreviewButton' + type Props = { deployJobs: Array, deployment: Deployment, @@ -110,8 +110,10 @@ export default class CurrentDeploymentPanel extends Component { const ec2Info = server && server.ec2Info const hasPreviouslyDeployed = deployment.deployJobSummaries.length > 0 return ( - Deployment Summary

}> - {deployJob && + + Deployment Summary + + {deployJob &&
Deployment in progress...
@@ -119,8 +121,8 @@ export default class CurrentDeploymentPanel extends Component { {deployJob.status.message}
- } - {hasPreviouslyDeployed && + } + {hasPreviouslyDeployed && - } - {hasPreviouslyDeployed - ? - : 'No current deployment found' - } - {ec2Info // If has EC2 info, show EC2 instances box. - ?
-

+ } + {hasPreviouslyDeployed + ? + : 'No current deployment found' + } + {ec2Info // If has EC2 info, show EC2 instances box. + ?
+

EC2 instances ({getActiveInstanceCount(deployment.ec2Instances)} active) - -

- {deployment.ec2Instances - ?
- {deployment.ec2Instances.map(instance => { - return ( - job.instanceId === instance.instanceId)} - instance={instance} - terminateEC2InstanceForDeployment={this.props.terminateEC2InstanceForDeployment} /> - ) - })} -
- : null - } -
- : null - } + +

+ {deployment.ec2Instances + ?
+ {deployment.ec2Instances.map(instance => { + return ( + job.instanceId === instance.instanceId)} + instance={instance} + terminateEC2InstanceForDeployment={this.props.terminateEC2InstanceForDeployment} /> + ) + })} +
+ : null + } +
+ : null + } +
) } @@ -226,7 +229,8 @@ class DeployJobSummary extends Component<{ } _onClickDownloadGraph = () => { - this.props.downloadBuildArtifact(this.props.deployment, 'Graph.obj', this._getJobId()) + const g = this.props.deployment.tripPlannerVersion === 'OTP_2' ? 'g' : 'G' + this.props.downloadBuildArtifact(this.props.deployment, `${g}raph.obj`, this._getJobId()) } _onClickDownloadReport = () => { diff --git a/lib/manager/components/deployment/CustomConfig.js b/lib/manager/components/deployment/CustomConfig.js index b7ad588e9..84499c38e 100644 --- a/lib/manager/components/deployment/CustomConfig.js +++ b/lib/manager/components/deployment/CustomConfig.js @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ // @flow import Icon from '@conveyal/woonerf/components/icon' @@ -10,10 +11,10 @@ import { Radio } from 'react-bootstrap' import { LinkContainer } from 'react-router-bootstrap' +import validator from 'validator' import * as deploymentActions from '../../actions/deployments' import {isValidJSONC} from '../../../common/util/json' - import type { Deployment } from '../../../types' @@ -30,6 +31,11 @@ const SAMPLE_ROUTER_CONFIG = `{ } }` +const CONFIG_OPTIONS = { + custom: 'custom', + url: 'url' +} + export default class CustomConfig extends Component<{ deployment: Deployment, label: string, @@ -38,24 +44,49 @@ export default class CustomConfig extends Component<{ }, {[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}) + getName = (option?: string) => { + let {name} = this.props + if (option === CONFIG_OPTIONS.url) { + name += 'Url' + } + return name } - _onChangeConfig = (evt: SyntheticInputEvent) => - this.setState({[this.props.name]: evt.target.value}) - - _onSaveConfig = () => { + _toggleCustomConfig = (evt: SyntheticInputEvent, option?: string) => { const {deployment, name, updateDeployment} = this.props + let value = 'https://' + if (name === 'customBuildConfig' && option === CONFIG_OPTIONS.custom) value = deployment[name] || SAMPLE_BUILD_CONFIG + if (name === 'customRouterConfig' && option === CONFIG_OPTIONS.custom) value = deployment[name] || SAMPLE_ROUTER_CONFIG + + // If no option, clear everything + if (!option) { + updateDeployment(deployment, {[name + 'Url']: null, [name]: null}) + } + + // If custom content, clear URL + if (option === CONFIG_OPTIONS.custom) { + updateDeployment(deployment, {[name + 'Url']: null, [name]: value}) + } + + // If custom URL, clear content + if (option === CONFIG_OPTIONS.url) { + updateDeployment(deployment, {[name + 'Url']: value}) + } + } + + _onChangeConfig = (evt: SyntheticInputEvent, option?: string) => { + const name = this.getName(option) + + this.setState({[name]: evt.target.value}) + } + + _onSaveConfig = (option?: string) => { + const {deployment, updateDeployment} = this.props + const name = this.getName(option) + const value = this.state[name] - if (!isValidJSONC(value)) return window.alert('Must provide valid JSON string.') + if (option === CONFIG_OPTIONS.custom && !isValidJSONC(value)) return window.alert('Must provide valid JSON string.') + else if (option === CONFIG_OPTIONS.url && !validator.isURL(value)) return window.alert('Must provide valid URL string.') else { updateDeployment(deployment, {[name]: value}) this.setState({[name]: undefined}) @@ -65,39 +96,48 @@ export default class CustomConfig extends Component<{ render () { const {deployment, name, label} = this.props const useCustom = deployment[name] !== null + const useCustomUrl = deployment[name + 'Url'] !== null const value = this.state[name] || deployment[name] + const urlValue = this.state[name + 'Url'] || deployment[name + 'Url'] const validJSON = isValidJSONC(value) + const validURL = !!urlValue && validator.isURL(urlValue) return (
{label} configuration
this._toggleCustomConfig(e)} inline> Project default this._toggleCustomConfig(e, CONFIG_OPTIONS.custom)} inline> Custom + this._toggleCustomConfig(e, CONFIG_OPTIONS.url)} + inline> + URL +

- {useCustom - ? `Use custom JSON defined below for ${label} configuration.` - : `Use the ${label} configuration defined in the project deployment settings.` - } + {useCustom && `Use custom JSON defined below for ${label} configuration.`} + {useCustomUrl && `Download ${label} configuration from URL defined below.`} + {!useCustom && !useCustomUrl && `Use the ${label} configuration defined in the project deployment settings.`} {' '} - {useCustom + {useCustom || useCustomUrl ? + disabled={!this.state[useCustomUrl ? name + 'Url' : name] || (useCustomUrl ? !validURL : !validJSON)} + onClick={() => this._onSaveConfig(useCustomUrl ? CONFIG_OPTIONS.url : CONFIG_OPTIONS.custom)}>Save :

) } diff --git a/lib/manager/components/deployment/CustomFileEditor.js b/lib/manager/components/deployment/CustomFileEditor.js index 9525a39fb..9a68a8244 100644 --- a/lib/manager/components/deployment/CustomFileEditor.js +++ b/lib/manager/components/deployment/CustomFileEditor.js @@ -282,7 +282,7 @@ export default class CustomFileEditor extends Component diff --git a/lib/manager/components/deployment/DeploymentConfigurationsPanel.js b/lib/manager/components/deployment/DeploymentConfigurationsPanel.js index daf9b3c52..2e4c23514 100644 --- a/lib/manager/components/deployment/DeploymentConfigurationsPanel.js +++ b/lib/manager/components/deployment/DeploymentConfigurationsPanel.js @@ -17,15 +17,15 @@ 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' +import CustomConfig from './CustomConfig' +import CustomFileEditor from './CustomFileEditor' + const TRIP_PLANNER_VERSIONS = [ { label: 'OTP 1.X', value: 'OTP_1' }, { label: 'OTP 2.X', value: 'OTP_2' } @@ -164,8 +164,9 @@ export default class DeploymentConfigurationsPanel extends Component<{ const { deployment, updateDeployment } = this.props const { customFileEditIdx, otp: options } = this.state return ( - OTP Configuration}> - + + OTP Configuration +
OTP jar file ({label: d.name, value: d.id}))} - placeholder={this.messages('pinnedDeployment.placeholder')} - value={project.pinnedDeploymentId} - /> - {this.messages('pinnedDeployment.help')} - - - - {' '} - {this.messages('autoDeploy.label')} - - ({label: d.name, value: d.id}))} + placeholder={this.messages('pinnedDeployment.placeholder')} + value={project.pinnedDeploymentId} + /> + {this.messages('pinnedDeployment.help')} + + + + {' '} + {this.messages('autoDeploy.label')} + + ({...r, index}))} labelKey={'route_name'} valueKey={'route_id'} - placeholder={'Jump to a Route'} + placeholder={this.messages('jumpToRoute')} // value={routeFilter} onChange={this._onSelectRoute} /> @@ -125,7 +144,7 @@ export default class RouteLayout extends Component { {error && - An error occurred while trying to fetch the data + {this.messages('errorOccurred')} } @@ -144,16 +163,13 @@ export default class RouteLayout extends Component { selectTab={selectTab} /> ))} - + {numPages > 1 && +
+ + {paginationItems} + +
+ } } @@ -171,6 +187,8 @@ class RouteRow extends Pure { selectTab: string => void } + messages = getComponentMessages('RouteLayout') + _changePatternRouteFilter (tabToSelect: string) { const {namespace, patternRouteFilterChange, selectTab} = this.props patternRouteFilterChange(namespace, this.props.routeId) @@ -218,17 +236,17 @@ class RouteRow extends Pure { diff --git a/lib/manager/components/transform/AddCustomFile.js b/lib/manager/components/transform/AddCustomFile.js new file mode 100644 index 000000000..ca7dad9df --- /dev/null +++ b/lib/manager/components/transform/AddCustomFile.js @@ -0,0 +1,102 @@ +// @flow + +import React, { Component } from 'react' + +import type { AddCustomFileProps, TransformProps } from '../../../types' +import CSV_VALIDATION_ERRORS from '../../util/enums/transform' +import { getComponentMessages } from '../../../common/util/config' + +import CustomCSVForm from './CustomCSVForm' + +/** + * Component that renders fields for AddCustomFile. This transformation shares csvData props with the ReplaceFileFromString transformation. + * TODO: adapt this transformation to include a file upload for larger custom files? + */ +export default class AddCustomFile extends Component, AddCustomFileProps> { + // Messages are for the child CSV Form component but since messages are shared across transformation types, + // the messages are being grouped under that component. + messages = getComponentMessages('AddCustomFile') + constructor (props: TransformProps) { + super(props) + this.state = {csvData: props.transformation.csvData, table: props.transformation.table} + } + + componentDidMount () { + this._updateErrors() + } + + componentDidUpdate (prevProps: TransformProps, prevState: AddCustomFileProps) { + if (prevState !== this.state) { + this._updateErrors() + } + } + + _handleChange = (evt: SyntheticInputEvent) => { + const newState = {...this.state, table: evt.target.value} + this.setState(newState) + } + + _onSaveCsvData = () => { + const csvData = this.state.csvData || null + const table = this.state.table + this.props.onSave({csvData, table}, this.props.index) + } + + _onChangeCsvData = (evt: {target: {name?: string, value: string}}) => { + const newState = {...this.state, csvData: evt.target.value} + this.setState(newState) + } + + _getValidationErrors (fields: AddCustomFileProps): Array { + const issues = [] + const { csvData, table } = fields + + // CSV data must be defined. + if (!csvData || csvData.length === 0) { + issues.push(CSV_VALIDATION_ERRORS.UNDEFINED_CSV_DATA) + } + if (!table) { + issues.push(CSV_VALIDATION_ERRORS.CSV_MUST_HAVE_NAME) + } + if (table && table.endsWith('.txt')) { + issues.push(CSV_VALIDATION_ERRORS.CSV_NAME_CONTAINS_TXT) + } + return issues + } + + /** + * Notify containing component of the resulting validation errors if any. + * @param fields: The updated state. If not set, the component state will be used. + */ + _updateErrors = (fields?: AddCustomFileProps) => { + const { onValidationErrors } = this.props + onValidationErrors(this._getValidationErrors(fields || this.state)) + } + + render () { + const {transformation} = this.props + const {csvData, table} = this.state + const inputIsSame = csvData === transformation.csvData && table === transformation.table + return ( +
+
+ .txt +
+ +
+ ) + } +} diff --git a/lib/manager/components/transform/CustomCSVForm.js b/lib/manager/components/transform/CustomCSVForm.js new file mode 100644 index 000000000..dfd9aac11 --- /dev/null +++ b/lib/manager/components/transform/CustomCSVForm.js @@ -0,0 +1,111 @@ +// @flow + +// $FlowFixMe Flow is outdated +import React, { useEffect, useState } from 'react' +import { Button } from 'react-bootstrap' +import { parseString } from '@fast-csv/parse' + +import { getComponentMessages } from '../../../common/util/config' + +type Props = { + buttonText?: string, + csvData?: ?string, + headerText?: string, + hideSaveButton?: boolean, + inputIsSame?: boolean, + name?: string, + onChangeCsvData: ({target: {name?: string, value: string}}) => void, + onSaveCsvData: () => void, + placeholder?: string, + validateHeaders?: boolean +} +const CustomCSVForm = (props: Props) => { + const [errorCount, setErrorCount] = useState(0) + + const { + buttonText, + csvData, + headerText, + hideSaveButton, + inputIsSame, + name, + onChangeCsvData, + onSaveCsvData, + placeholder + } = props + + useEffect(() => { + // Default to true + const validateHeaders = props.validateHeaders !== undefined ? props.validateHeaders : true + setErrorCount(0) + + parseString(csvData, { headers: validateHeaders }) + .on('error', _ => setErrorCount(errorCount + 1)) + }, [csvData]) + + const numLines = !csvData ? 0 : csvData.split(/\r*\n/).length + const messages = getComponentMessages('CustomCSVForm') + + const csvIsValid = errorCount === 0 + + return ( +
+