diff --git a/.ddev/app-build/Dockerfile b/.ddev/app-build/Dockerfile new file mode 100644 index 00000000..e7fc1dd9 --- /dev/null +++ b/.ddev/app-build/Dockerfile @@ -0,0 +1,35 @@ +ARG NODE_VERSION=16 + +FROM node:${NODE_VERSION}-buster as nodeJs + +FROM ruby:3.0.4 + +RUN mkdir /app + +WORKDIR /app + +# Instead of building node from source, just pulling a compiled version already +COPY --from=nodeJs /usr/local/bin/node /usr/local/bin/node +COPY --from=nodeJs /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=nodeJs /opt /opt + +# Making the correct symlinks needed for node +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm +RUN ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + +RUN npm install -g pm2 + +# Building App + +RUN gem install bundler + +# These are copied into .ddev/app-build in a pre-start hook +COPY Gemfile \ + Gemfile.lock \ + package.json \ + package-lock.json \ + ./ + +RUN npm ci + +RUN bundle install diff --git a/.ddev/commands/app/make b/.ddev/commands/app/make new file mode 100755 index 00000000..47c933b6 --- /dev/null +++ b/.ddev/commands/app/make @@ -0,0 +1,7 @@ +#!/bin/sh + +## Description: Run make commands +## Usage: make +## Example: "ddev make" + +make "$@" diff --git a/.ddev/commands/app/pm2 b/.ddev/commands/app/pm2 new file mode 100755 index 00000000..418ba00e --- /dev/null +++ b/.ddev/commands/app/pm2 @@ -0,0 +1,7 @@ +#!/bin/sh + +## Description: Run make commands +## Usage: pm2 +## Example: "ddev pm2" + +pm2 "$@" diff --git a/.ddev/config.yaml b/.ddev/config.yaml new file mode 100644 index 00000000..caec7b91 --- /dev/null +++ b/.ddev/config.yaml @@ -0,0 +1,270 @@ +name: foia.gov +type: php +docroot: "" +php_version: "8.0" +webserver_type: nginx-fpm +router_http_port: "80" +router_https_port: "443" +xdebug_enabled: false +additional_hostnames: [] +additional_fqdns: [] +database: + type: mariadb + version: "10.4" +nfs_mount_enabled: false +mutagen_enabled: false +use_dns_when_possible: true +composer_version: "2" +web_environment: [] +nodejs_version: "16" +hooks: + pre-start: + - exec-host: 'cp Gemfile Gemfile.lock package.json package-lock.json .ddev/app-build/' + post-start: + - exec: 'pm2 stop node-serve 2> /dev/null || true && pm2 start --name node-serve /usr/local/bin/node -- js/dev-server.js' + service: app + - exec: 'pm2 stop webpack 2> /dev/null || true && pm2 start --name webpack /usr/local/bin/npm -- run serve:watch' + service: app + +#hooks: +# post-start: +# - exec: 'cd /var/www/html && rbenv local && sudo gem install bundler && sudo bundle install && npm ci' +# service: web + +# Key features of ddev's config.yaml: + +# name: # Name of the project, automatically provides +# http://projectname.ddev.site and https://projectname.ddev.site + +# type: # drupal6/7/8, backdrop, typo3, wordpress, php + +# docroot: # Relative path to the directory containing index.php. + +# php_version: "7.4" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2" + +# You can explicitly specify the webimage but this +# is not recommended, as the images are often closely tied to ddev's' behavior, +# so this can break upgrades. + +# webimage: # nginx/php docker image. + +# database: +# type: # mysql, mariadb +# version: # database version, like "10.3" or "8.0" +# Note that mariadb_version or mysql_version from v1.18 and earlier +# will automatically be converted to this notation with just a "ddev config --auto" + +# router_http_port: # Port to be used for http (defaults to port 80) +# router_https_port: # Port for https (defaults to 443) + +# xdebug_enabled: false # Set to true to enable xdebug and "ddev start" or "ddev restart" +# Note that for most people the commands +# "ddev xdebug" to enable xdebug and "ddev xdebug off" to disable it work better, +# as leaving xdebug enabled all the time is a big performance hit. + +# xhprof_enabled: false # Set to true to enable xhprof and "ddev start" or "ddev restart" +# Note that for most people the commands +# "ddev xhprof" to enable xhprof and "ddev xhprof off" to disable it work better, +# as leaving xhprof enabled all the time is a big performance hit. + +# webserver_type: nginx-fpm # or apache-fpm + +# timezone: Europe/Berlin +# This is the timezone used in the containers and by PHP; +# it can be set to any valid timezone, +# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# For example Europe/Dublin or MST7MDT + +# composer_root: +# Relative path to the composer root directory from the project root. This is +# the directory which contains the composer.json and where all Composer related +# commands are executed. + +# composer_version: "2" +# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 +# to use the latest major version available at the time your container is built. +# It is also possible to use each other Composer version channel. This includes: +# - 2.2 (latest Composer LTS version) +# - stable +# - preview +# - snapshot +# Alternatively, an explicit Composer version may be specified, for example "2.2.18". +# To reinstall Composer after the image was built, run "ddev debug refresh". + +# nodejs_version: "16" +# change from the default system Node.js version to another supported version, like 12, 14, 17, 18. +# Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any +# Node.js version, including v6, etc. + +# additional_hostnames: +# - somename +# - someothername +# would provide http and https URLs for "somename.ddev.site" +# and "someothername.ddev.site". + +# additional_fqdns: +# - example.com +# - sub1.example.com +# would provide http and https URLs for "example.com" and "sub1.example.com" +# Please take care with this because it can cause great confusion. + +# upload_dir: custom/upload/dir +# would set the destination path for ddev import-files to /custom/upload/dir +# When mutagen is enabled this path is bind-mounted so that all the files +# in the upload_dir don't have to be synced into mutagen + +# working_dir: +# web: /var/www/html +# db: /home +# would set the default working directory for the web and db services. +# These values specify the destination directory for ddev ssh and the +# directory in which commands passed into ddev exec are run. + +# omit_containers: [db, dba, ddev-ssh-agent] +# Currently only these containers are supported. Some containers can also be +# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit +# the "db" container, several standard features of ddev that access the +# database container will be unusable. In the global configuration it is also +# possible to omit ddev-router, but not here. + +# nfs_mount_enabled: false +# Great performance improvement but requires host configuration first. +# See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs + +# mutagen_enabled: false +# Performance improvement using mutagen asynchronous updates. +# See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen + +# fail_on_hook_fail: False +# Decide whether 'ddev start' should be interrupted by a failing hook + +# host_https_port: "59002" +# The host port binding for https can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_webserver_port: "59001" +# The host port binding for the ddev-webserver can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_db_port: "59002" +# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic +# unless explicitly specified. + +# phpmyadmin_port: "8036" +# phpmyadmin_https_port: "8037" +# The PHPMyAdmin ports can be changed from the default 8036 and 8037 + +# host_phpmyadmin_port: "8036" +# The phpmyadmin (dba) port is not normally bound on the host at all, instead being routed +# through ddev-router, but it can be specified and bound. + +# mailhog_port: "8025" +# mailhog_https_port: "8026" +# The MailHog ports can be changed from the default 8025 and 8026 + +# host_mailhog_port: "8025" +# The mailhog port is not normally bound on the host at all, instead being routed +# through ddev-router, but it can be bound directly to localhost if specified here. + +# webimage_extra_packages: [php7.4-tidy, php-bcmath] +# Extra Debian packages that are needed in the webimage can be added here + +# dbimage_extra_packages: [telnet,netcat] +# Extra Debian packages that are needed in the dbimage can be added here + +# use_dns_when_possible: true +# If the host has internet access and the domain configured can +# successfully be looked up, DNS will be used for hostname resolution +# instead of editing /etc/hosts +# Defaults to true + +# project_tld: ddev.site +# The top-level domain used for project URLs +# The default "ddev.site" allows DNS lookup via a wildcard +# If you prefer you can change this to "ddev.local" to preserve +# pre-v1.9 behavior. + +# ngrok_args: --basic-auth username:pass1234 +# Provide extra flags to the "ngrok http" command, see +# https://ngrok.com/docs#http or run "ngrok http -h" + +# disable_settings_management: false +# If true, ddev will not create CMS-specific settings files like +# Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php +# In this case the user must provide all such settings. + +# You can inject environment variables into the web container with: +# web_environment: +# - SOMEENV=somevalue +# - SOMEOTHERENV=someothervalue + +# no_project_mount: false +# (Experimental) If true, ddev will not mount the project into the web container; +# the user is responsible for mounting it manually or via a script. +# This is to enable experimentation with alternate file mounting strategies. +# For advanced users only! + +# bind_all_interfaces: false +# If true, host ports will be bound on all network interfaces, +# not just the localhost interface. This means that ports +# will be available on the local network if the host firewall +# allows it. + +# default_container_timeout: 120 +# The default time that ddev waits for all containers to become ready can be increased from +# the default 120. This helps in importing huge databases, for example. + +#web_extra_exposed_ports: +#- name: nodejs +# container_port: 3000 +# http_port: 2999 +# https_port: 3000 +#- name: something +# container_port: 4000 +# https_port: 4000 +# http_port: 3999 +# Allows a set of extra ports to be exposed via ddev-router +# The port behavior on the ddev-webserver must be arranged separately, for example +# using web_extra_daemons. +# For example, with a web app on port 3000 inside the container, this config would +# expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 +# web_extra_exposed_ports: +# - container_port: 3000 +# http_port: 9998 +# https_port: 9999 + +#web_extra_daemons: +#- name: "http-1" +# command: "/var/www/html/node_modules/.bin/http-server -p 3000" +# directory: /var/www/html +#- name: "http-2" +# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" +# directory: /var/www/html + +# override_config: false +# By default, config.*.yaml files are *merged* into the configuration +# But this means that some things can't be overridden +# For example, if you have 'nfs_mount_enabled: true'' you can't override it with a merge +# and you can't erase existing hooks or all environment variables. +# However, with "override_config: true" in a particular config.*.yaml file, +# 'nfs_mount_enabled: false' can override the existing values, and +# hooks: +# post-start: [] +# or +# web_environment: [] +# or +# additional_hostnames: [] +# can have their intended affect. 'override_config' affects only behavior of the +# config.*.yaml file it exists in. + +# Many ddev commands can be extended to run tasks before or after the +# ddev command is executed, for example "post-start", "post-import-db", +# "pre-composer", "post-composer" +# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more +# information on the commands that can be extended and the tasks you can define +# for them. Example: +#hooks: diff --git a/.ddev/docker-compose.app.yaml b/.ddev/docker-compose.app.yaml new file mode 100644 index 00000000..9971f38a --- /dev/null +++ b/.ddev/docker-compose.app.yaml @@ -0,0 +1,22 @@ +version: "3.6" +services: + app: + container_name: ddev-${DDEV_SITENAME}-app + build: app-build + expose: + - 6006 + - 4000 + command: "sleep infinity" + volumes: + - "../:/app" + - ".:/mnt/ddev_config" + working_dir: /app + environment: + APP_ENV: ddev + VIRTUAL_HOST: $DDEV_HOSTNAME + HTTP_EXPOSE: "80:4000,6006" + HTTPS_EXPOSE: "443:4000" + labels: + com.ddev.site-name: ${DDEV_SITENAME} + com.ddev.approot: $DDEV_APPROOT + networks: [ default, ddev_default ] diff --git a/.ddev/nginx_full/nginx-site.conf b/.ddev/nginx_full/nginx-site.conf new file mode 100644 index 00000000..1b1332e4 --- /dev/null +++ b/.ddev/nginx_full/nginx-site.conf @@ -0,0 +1,40 @@ +# ddev default (PHP project type) config + +# If you want to take over this file and customize it, remove the line above +# and ddev will respect it and won't overwrite the file. +# See https://ddev.readthedocs.io/en/stable/users/extend/customization-extendibility/#providing-custom-nginx-configuration + +server { + listen 80 default_server; + listen 443 ssl default_server; + + root /var/www/html; + + ssl_certificate /etc/ssl/certs/master.crt; + ssl_certificate_key /etc/ssl/certs/master.key; + + include /etc/nginx/monitoring.conf; + + index index.htm index.html; + + location / { + proxy_pass http://app:4000; + } + + # Disable sendfile as per https://docs.vagrantup.com/v2/synced-folders/virtualbox.html + sendfile off; + error_log /dev/stdout info; + access_log /var/log/nginx/access.log; + + # Prevent clients from accessing hidden files (starting with a dot) + # This is particularly important if you store .htpasswd files in the site hierarchy + # Access to `/.well-known/` is allowed. + # https://www.mnot.net/blog/2010/04/07/well-known + # https://tools.ietf.org/html/rfc5785 + location ~* /\.(?!well-known\/) { + deny all; + } + + include /etc/nginx/common.d/*.conf; + include /mnt/ddev_config/nginx/*.conf; +} diff --git a/.github/workflows/deploy-to-uat.yml b/.github/workflows/deploy-to-uat.yml index db3bfbde..10601ad3 100644 --- a/.github/workflows/deploy-to-uat.yml +++ b/.github/workflows/deploy-to-uat.yml @@ -18,9 +18,9 @@ jobs: run: | npm i bundle install - - name: Run tests + - name: Build run: | - NODE_ENV=production make test + NODE_ENV=production make build # Install SSH Key - name: Install SSH key uses: webfactory/ssh-agent@v0.7.0 diff --git a/.gitignore b/.gitignore index 753ef7d3..19711fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ _site node_modules/ npm-debug.log* /www.foia.gov/assets/ + +# These copied from root in DDEV pre-start hook +.ddev/app-build/Gemfile +.ddev/app-build/Gemfile.lock +.ddev/app-build/package.json +.ddev/app-build/package-lock.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..6f7f377b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v16 diff --git a/Gemfile.lock b/Gemfile.lock index 318de3b4..f38ee546 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,7 +92,7 @@ DEPENDENCIES webrick (~> 1.7) RUBY VERSION - ruby 3.0.1p64 + ruby 3.0.4p208 BUNDLED WITH 2.3.24 diff --git a/Makefile b/Makefile index 2a65ebe8..d39564a8 100644 --- a/Makefile +++ b/Makefile @@ -30,12 +30,18 @@ build: cp -R node_modules/uswds/dist/img www.foia.gov/assets cp node_modules/@usdoj/uswds-external-link/extlink.min.js www.foia.gov/assets/js/ JEKYLL_ENV=$(JEKYLL_ENV) bundle exec jekyll build $(JEKYLL_OPTS) + # Reset permissions for DDEV user + chown -R --reference=js _site build.reload: JEKYLL_ENV=$(JEKYLL_ENV) bundle exec jekyll build $(JEKYLL_OPTS) + # Reset permissions for DDEV user + chown -R --reference=js _site build.dev: JEKYLL_ENV=$(JEKYLL_ENV) bundle exec jekyll build $(JEKYLL_OPTS) --watch --incremental + # Reset permissions for DDEV user + chown -R --reference=js _site clean: rm -rf www.foia.gov/assets @@ -43,7 +49,7 @@ clean: serve.dev: -pkill -9 -f "node js/dev-server.js" - APP_ENV=development node js/dev-server.js + node js/dev-server.js serve: ./node_modules/.bin/npm-run-all --parallel serve:watch serve:dev diff --git a/_config.yml b/_config.yml index f1daf923..69a2e1d3 100644 --- a/_config.yml +++ b/_config.yml @@ -48,7 +48,14 @@ navigation: title: Research - href: /opengov.html title: Additional government resources - - href: /#agency-search + - href: /wizard.html + title: Search tool + children: + - href: /wizard.html + title: Use our search tool + - href: /how-wizard-works.html + title: How it works + - href: /agency-search.html title: Create a request - href: /data.html title: Agency FOIA data diff --git a/docs/development.md b/docs/development.md index ab86afeb..1a35f99c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,7 +1,11 @@ # Development +## Prerequisites (ddev) -## Prerequisites +* [DDEV](https://ddev.readthedocs.io/en/stable/) + + +## Prerequisites (local) * [Ruby](https://www.ruby-lang.org/en/) 2.3.4 * [Bundler](https://bundler.io/) @@ -14,7 +18,33 @@ Once you've got Ruby installed, install bundler. $ gem install bundler -## Setup +## Setup (ddev) + +Build/start the containers. This will run the dev-server.js and webpack in watch mode in two PM2 services. You can view the site: https://foia.gov.ddev.site/ + + $ ddev start + +View webpack/jekyll logs, or status: + + $ ddev pm2 logs + $ ddev pm2 ls + +Run tests (everything but cucumber tests are working): + + $ ddev make test + +To preview a production build, stop the dev webpack service and do a prod build. This will update https://foia.gov.ddev.site/ + + $ ddev pm2 stop webpack + $ ddev make build + +To return to development: + + $ ddev pm2 start webpack + +**Tip:** With this setup, do not use `make` outside the container, as this will lead to permissions problems inside. + +## Setup (local) Install the dependencies. @@ -77,3 +107,9 @@ Instead of the current location in `www.foia.gov/foia-style.scss`. This will al Webpack's `sass-loader` has many options such as advanced source maps and compression which can further speed up development. Webpack can also detect changes to and recompile other assets such as images, fonts and SVG files. + +### Type-check the Wizard app + +``` +npx tsc --watch --project js/wizard.tsconfig.json +``` diff --git a/docs/wizard-app.md b/docs/wizard-app.md new file mode 100644 index 00000000..a97841ec --- /dev/null +++ b/docs/wizard-app.md @@ -0,0 +1,113 @@ +# FOIA Wizard app + +The wizard app is a ReactJS application located at `wizard.html` which provides shortcuts to already public records and/or directs users to particular agencies to make FOIA requests. + +## Application Structure + + +### Entry point + +`/js/wizard.jsx` + +### Page + +`/js/pages/wizard.jsx` + +The `WizardPageWrapper` component is responsible for managing the application. +It is implemented as a flux container to load the `agencyComponentStore` and an +inner component to route between pages (stored in `/js/components/wizard_pages`). + +### Stores + +Besides the agency component store, the wizard implements a [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) store in `js/stores/wizard_store.js`. (Types +for objects in this store are documented in `js/wizard-types.d.ts`.) + +Notable is the type `WizardVars` which represent the raw state of the UX. The `use_wizard()` +React hook, defined at the bottom simplifies the state exposed to pages and components. + +## Predefined user flows + +Each predefined flow is implemented as a `WizardTopic` object defined in `js/models/wizard_topics.js`. +Its `journey` property is the first question object the user experiences, modeled as a +nested object of answers and additional questions, and other examples of `WizardActivity`. + +In this way, actions within the store can move the user along the journey, eventually ending +with a `WizardSummary`, which either shows a message or a set of links and agencies fetched +within the `submitRequest()` action. Strong type definitions of all the involved `WizardActivity` +instances ensure usable IDE auto-complete and even type-checking much of the Wizard app using th +TypeScript compiler: + +``` +npm i -g typescript +npx tsc --project js/wizard.tsconfig.json +``` + +Helper functions like `question`, `answer`, `yesNoQuestion`, `continueStep`, and `summary` can +be used to simplify journey creation. E.g. the tax records journey: + +```js +/** @type {WizardQuestion} */ +const taxTypeQuestion = question('q1', [ + answer( + 'a15', + summary('m16'), + extraMessages.a15, + ), + answer( + 'a16', + summary('m17'), + extraMessages.a16, + ), + answer( + 'a17', + summary('m18'), + extraMessages.a17, + ), + answer( + 'startOver', + { type: 'start-over' }, + ), +]); +``` + +In this journey, if the user's answer is ID `a15`, they proceed immediately to the summary page +showing the content with ID `m16`. (Unfortunately the 3rd argument of `answer()` doesn't accept +a message ID, so here we're just pointing to a string in another JS file. This argument is used +to modify the query displayed to the user at the top of the page (overwriting what they typed)). + +### Adding a question + +To do this, you would find the object that currently represents an activity within the journey, +such as a `WizardContinue` or `WizardSummary`. Then you would replace that object with a new +`WizardQuestion` (or use `question()` or `yesNoQuestion()`). The other object(s) can be nested +inside a particular answer within the question. + +``` +Old: question -> answer -> summary + -> answer -> summary + +New: question -> answer -> summary + -> answer -> NEW QUESTION -> answer -> summary + -> answer -> summary +``` + +## Message strings + +Strings like `m15` are populated from Drupal settings, fetched from the endpoint `/api/foia_wizard`. +Many question and answer strings are hardcoded in the file `js/models/wizard_extra_messages.js`, but +these could eventually be migrated to Drupal settings. + +Some object message ID fields are populated instead with string literals: + +``` +'m15' --> Use the string with ID = m15, likely populated from a Drupal WYSIWYG field. + +'literal:Hello World' --> Use the string "Hello World" +``` + +### Client-side message transformation + +Within the Wizard app, messages are fed through the function `useWizard().getMessage()` and are +further processed in the `RichText` component (`js/components/wizard_component_rich_text.jsx`) +resulting in markup being transformed to display links as cards, or with external icons, or +to open in new tabs. diff --git a/features/Homepage.feature b/features/Homepage.feature index 50973b0c..c2b50446 100644 --- a/features/Homepage.feature +++ b/features/Homepage.feature @@ -12,14 +12,8 @@ Feature: Homepage Scenario: The introduction banner appears on the page Then I should see "The basic function of the Freedom of Information Act" - Scenario: The agency type-ahead works - Then I should see "Search" in the "homepage search button" element - And I enter "OIP" into the homepage agency search box - Then I should see "In addition to its policy functions, OIP oversees agency compliance with the FOIA." - - Scenario: The agency a-to-z list works - And I hard click on "the A-to-Z button" - And I hard click on "the A button" - And I hard click on "the last item in the A section" - And I wait 5 seconds - Then I should see "a premier retirement community with exceptional residential care" + Scenario: The "Start your journey" CTA appears on the page + Then I should see "Start your FOIA journey" + And I should see "Learn about the FOIA process" + And I should see "Use our search tool" + And I should see "Start a request with a specific agency" diff --git a/features/step_definitions/mink-gherkin.js b/features/step_definitions/mink-gherkin.js index e18de53b..edeb1265 100644 --- a/features/step_definitions/mink-gherkin.js +++ b/features/step_definitions/mink-gherkin.js @@ -38,6 +38,30 @@ const customSteps = [ return inputHandle.dispose(); }, }, + { + pattern: /^(?:|I )enter "([^"]*)" into the agency search box/, + async callback(value) { + const inputSelector = this.mink.getSelector('the agency search box'); + const inputHandle = await this.mink.page.$(inputSelector); + await inputHandle.type(value); + await Promise.delay(1 * 1000); + await inputHandle.press('Enter'); + await Promise.delay(1 * 1000); + return inputHandle.dispose(); + }, + }, + { + pattern: /^(?:|I )enter "([^"]*)" into the wizard query box/, + async callback(value) { + const inputSelector = this.mink.getSelector('the wizard query box'); + const inputHandle = await this.mink.page.$(inputSelector); + await inputHandle.type(value); + await Promise.delay(1 * 1000); + await inputHandle.press('Enter'); + await Promise.delay(1 * 1000); + return inputHandle.dispose(); + }, + }, { pattern: /^(?:|I )check the box for the year "([^"]*)"/, async callback(value) { @@ -48,6 +72,36 @@ const customSteps = [ return inputHandle.dispose(); }, }, + { + pattern: /^(?:|I )select the radio option for the answer "Yes"/, + async callback() { + const inputSelector = this.mink.getSelector('the first radio option'); + const inputHandle = await this.mink.page.$(inputSelector); + await Promise.delay(1 * 1000); + await inputHandle.evaluate(b => b.click()); + return inputHandle.dispose(); + }, + }, + { + pattern: /^(?:|I )select the radio option for the answer "Yes, I would like to do another search."/, + async callback() { + const inputSelector = this.mink.getSelector('the first radio option'); + const inputHandle = await this.mink.page.$(inputSelector); + await Promise.delay(1 * 1000); + await inputHandle.evaluate(b => b.click()); + return inputHandle.dispose(); + }, + }, + { + pattern: /^(?:|I )select the radio option for the answer "Copy or transcript of tax return"/, + async callback() { + const inputSelector = this.mink.getSelector('the first radio option'); + const inputHandle = await this.mink.page.$(inputSelector); + await Promise.delay(1 * 1000); + await inputHandle.evaluate(b => b.click()); + return inputHandle.dispose(); + }, + }, { pattern: /^(?:|I )choose "([^"]*)" from the data type dropdown/, async callback(value) { diff --git a/features/support/mink.js b/features/support/mink.js index b9e6b4e2..2c92c5ae 100644 --- a/features/support/mink.js +++ b/features/support/mink.js @@ -10,18 +10,24 @@ const driver = new mink.Mink({ selectors: { "homepage search button": ".usa-search-submit-text", "the homepage search box": "#search-field-big", + "the agency search box": "#agency-search", "the annual report search box": "#agency-component-search-1", "the A-to-Z button": "button[aria-controls='a1']", "the A button": "button[aria-controls='A']", "the last item in the A section": "#A li:last-child span", "the start request button": ".start-request", - "the first agency suggestion": ".tt-suggestion:first-child", + "the first agency suggestion": ".foia-component-card:first-of-type", + "the first radio option": "input[type='radio']:first-of-type", "the View Report button": "button[value='view']", "the data type dropdown": "select[name='data_type']", "the Select All Agencies button": ".select-all-agencies > a", "the Hero image credit": "a[href='https://commons.wikimedia.org/wiki/File:Usdepartmentofjustice.jpg']", "the justice.gov link": "a[href='http://www.justice.gov']", "the external link script": "script[src='/assets/js/extlink.min.js']", + "the wizard primary button": "button.w-component-button.usa-button.usa-button-primary-alt.usa-button-big", + "the Tax records topic button": "div.w-component-pill-group > ul > li:nth-child(2) > button", + "the wizard query box": "textarea.w-component-form-item__element", + "external link card": "a.foia-component-card.foia-component-card--alt.foia-component-card--ext" } }); diff --git a/js/actions/index.js b/js/actions/index.js index f43fe186..b3086315 100644 --- a/js/actions/index.js +++ b/js/actions/index.js @@ -136,7 +136,7 @@ export const requestActions = { }); const referenceFields = includeReferenceFields || { - agency_component: ['title', 'abbreviation', 'agency', 'status'], + agency_component: ['title', 'abbreviation', 'agency', 'description', 'status'], agency: ['name', 'abbreviation', 'description', 'category'], 'agency.category': ['name'], }; diff --git a/js/agency_search.jsx b/js/agency_search.jsx new file mode 100644 index 00000000..55c106e4 --- /dev/null +++ b/js/agency_search.jsx @@ -0,0 +1,10 @@ +import 'babel-polyfill'; +import React from 'react'; +import { render } from 'react-dom'; + +import AgencySearchPage from 'pages/agency_search'; + +render( + , + document.getElementById('agency-search-react-app'), +); diff --git a/js/components/agencies_by_category.jsx b/js/components/agencies_by_category.jsx deleted file mode 100644 index a14cc0a9..00000000 --- a/js/components/agencies_by_category.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Map } from 'immutable'; -import React from 'react'; -import PropTypes from 'prop-types'; - -/** - * A list of agencies by category. - */ -function AgenciesByCategory({ agencies, agencyFinderDataComplete, onAgencySelect }) { - const loading = !agencyFinderDataComplete; - const agenciesByCategory = {}; - agencies.forEach((agency) => { - if (agency.category && agency.category.name) { - if (!agenciesByCategory[agency.category.name]) { - agenciesByCategory[agency.category.name] = []; - } - agenciesByCategory[agency.category.name].push(agency); - } - }); - - const idFromName = (name) => name - .toLowerCase() - .replace(/ /g, '-') - .replace(/[^\w-]+/g, ''); - - return ( -
    -
  • - -
    -
      - {Object.entries(agenciesByCategory) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([categoryName, categoryAgencies]) => ( -
    • - - -
    • - ))} -
    -
    -
  • -
- ); -} - -AgenciesByCategory.propTypes = { - agencies: PropTypes.instanceOf(Map).isRequired, - agencyFinderDataComplete: PropTypes.bool.isRequired, - onAgencySelect: PropTypes.func.isRequired, -}; - -AgenciesByCategory.defaultProps = { - agencies: new Map(), - agencyFinderDataComplete: false, -}; - -export default AgenciesByCategory; diff --git a/js/components/agency_component_finder.jsx b/js/components/agency_component_finder.jsx deleted file mode 100644 index f932bdfe..00000000 --- a/js/components/agency_component_finder.jsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Map, List } from 'immutable'; -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import tokenizers from '../util/tokenizers'; - -// Only load typeahead in the browser (avoid loading it for tests) -let Bloodhound; -if (typeof window !== 'undefined') { - Bloodhound = require('typeahead.js/dist/bloodhound'); // eslint-disable-line global-require - require('typeahead.js/dist/typeahead.jquery'); // eslint-disable-line global-require -} - -// Expects agencies as a sequence type -function datums({ agencies, agencyComponents }) { - // Keep an index of centralized agencies for quick lookup - const centralizedAgencyIndex = {}; - - return agencies - .map((agency) => { - if (agency.isCentralized()) { - // Warning: Side-effect - // Add the agency to the index of centralized agencies - centralizedAgencyIndex[agency.id] = true; - } - - // Add a title property for common displayKey - return Object.assign(agency.toJS(), { title: agency.name }); - }) - .toJS() - // Include decentralized agency components in typeahead - .concat( - agencyComponents.toJS().filter( - (agencyComponent) => !(agencyComponent.agency.id in centralizedAgencyIndex), - ), - ); -} - -class AgencyComponentFinder extends Component { - constructor(props) { - super(props); - - this.isIndexed = false; - } - - componentDidMount() { - this.bloodhound = new Bloodhound({ - local: [], - sorter: (a, b) => { - // Ensure that agencies come before components and vice versa. - if (a.type === 'agency' && b.type !== 'agency') { - return -1; - } - if (a.type !== 'agency' && b.type === 'agency') { - return 1; - } - // Otherwise sort by title/name. - const aName = (a.type === 'agency') ? a.name : a.title; - const bName = (b.type === 'agency') ? b.name : b.title; - if (aName < bName) { - return -1; - } if (aName > bName) { - return 1; - } - return 0; - }, - identify: (datum) => datum.id, - queryTokenizer: Bloodhound.tokenizers.whitespace, - datumTokenizer: (datum) => ( - datum.type === 'agency' - ? ( - // For agencies - [] - .concat(Bloodhound.tokenizers.nonword(datum.name)) - .concat(Bloodhound.tokenizers.whitespace(datum.abbreviation)) - ) : ( - // For agency components - [] - .concat(Bloodhound.tokenizers.nonword(datum.title)) - .concat( - datum.abbreviation - ? Bloodhound.tokenizers.whitespace(datum.abbreviation) - : tokenizers.firstLetterOfEachCapitalizedWord(datum.title), - ) - .concat(Bloodhound.tokenizers.whitespace(datum.agency.name)) - .concat(Bloodhound.tokenizers.whitespace(datum.agency.abbreviation)) - ) - ), - }); - - // If we have all the data already then index it. If we're still waiting on - // data, we'll index when we receive the complete props. - if (this.props.agencyFinderDataComplete) { - this.index(); - } - - // Initialize the typeahead input element - if (!this.typeaheadInput) { - return; - } - - function display(datum) { - return datum.agency ? `${datum.title} (${datum.agency.name})` : datum.title; - } - - this.typeahead = $(this.typeaheadInput).typeahead({ - highlight: true, - hint: false, - }, { - name: 'agencies', - display, - source: this.bloodhound.ttAdapter(), - templates: { - suggestion: (datum) => $('
').addClass(datum.type).text(display(datum)), - }, - }) - .bind('typeahead:select', (e, suggestion) => this.props.onAgencyChange(suggestion)); - } - - componentDidUpdate() { - // Indexing the typeahead is expensive and if we do it in batches, it gets - // complicated to calculate which agencies are centralized vs - // decentralized. Wait until we've received all the agency finder data - // before indexing. - if (!this.props.agencyFinderDataComplete) { - return; - } - - this.index(); - } - - index() { - if (this.isIndexed) { - return; - } - - // There is no update, only initialize. We assume that the component is only - // initialized after all the data is ready. Any updates are not significant - // enough to warrant an update. We only need title and abbreviation to - // render which should be available once the agency finder data fetch is - // complete. - this.isIndexed = true; - const { agencies, agencyComponents } = this.props; - - this.bloodhound.clear(); // Just in case - this.bloodhound.add(datums({ - agencies: agencies.valueSeq(), // Pull the values, convert to sequence - agencyComponents, - })); - } - - render() { - const { agencyFinderDataProgress } = this.props; - const loading = !this.props.agencyFinderDataComplete; - const onSubmit = (e) => { - e.preventDefault(); - this.bloodhound.search(this.typeahead.typeahead('val'), (suggestions) => { - if (suggestions.length) { - // Trigger the selection event on the first suggestion and close the - // typeahead - this.typeahead.trigger('typeahead:select', suggestions[0]); - this.typeahead.typeahead('close'); - } - }); - }; - - const buttonClasses = ['usa-button', 'usa-sr-hidden']; - if (loading) { - buttonClasses.push('usa-button-disabled'); - } else { - buttonClasses.push('usa-button-primary'); - } - - return ( -
-
- - { this.typeaheadInput = input; }} - /> - -
-
- ); - } -} - -AgencyComponentFinder.propTypes = { - /* eslint-disable react/no-unused-prop-types */ - agencies: PropTypes.instanceOf(Map), - agencyComponents: PropTypes.instanceOf(List), - /* eslint-enable react/no-unused-prop-types */ - onAgencyChange: PropTypes.func.isRequired, - agencyFinderDataComplete: PropTypes.bool.isRequired, - agencyFinderDataProgress: PropTypes.number, -}; - -AgencyComponentFinder.defaultProps = { - agencies: new Map(), - agencyComponents: new List(), - agencyFinderDataProgress: 0, -}; - -export default AgencyComponentFinder; diff --git a/js/components/agency_component_preview.jsx b/js/components/agency_component_preview.jsx index d38a8641..e606fefc 100644 --- a/js/components/agency_component_preview.jsx +++ b/js/components/agency_component_preview.jsx @@ -7,14 +7,20 @@ import NonInteroperableInfo from './non_interoperable_info'; import { AgencyComponent } from '../models'; import domify from '../util/request_form/domify'; -function AgencyComponentPreview({ onAgencySelect, agencyComponent, isCentralized }) { +function AgencyComponentPreview({ + onAgencySelect, agencyComponent, isCentralized, setShowTips, setDestinationHref, +}) { const description = AgencyComponent.agencyMission(agencyComponent); const requestUrl = `/request/agency-component/${agencyComponent.id}/`; + const recordsHeld = (agencyComponent.field_commonly_requested_records || '') + .split(/\r?\n/) + .map((el) => el.trim()) + .filter((el) => el !== ''); const onSelect = () => onAgencySelect(agencyComponent.agency); return ( -
-
+
+
{ !isCentralized && (

@@ -34,7 +40,7 @@ function AgencyComponentPreview({ onAgencySelect, agencyComponent, isCentralized { !agencyComponent.request_form && }

-
+
{ description && (
@@ -47,9 +53,19 @@ function AgencyComponentPreview({ onAgencySelect, agencyComponent, isCentralized
+ {recordsHeld.length > 0 && ( +
+

Commonly requested documents

+
    + {recordsHeld.map((item) => ( +
  • {item}
  • + ))} +
+
+ )} { agencyComponent.request_data_year && } -
+

The records or information you’re looking for may already be public.

@@ -58,7 +74,7 @@ function AgencyComponentPreview({ onAgencySelect, agencyComponent, isCentralized

Visit the agency’s {' '} - website + website {' '} to learn more.

@@ -68,19 +84,22 @@ function AgencyComponentPreview({ onAgencySelect, agencyComponent, isCentralized

To see what’s been made available, you can visit an agency’s {' '} - FOIA library + FOIA library .

)}
{ agencyComponent.request_form && ( - - Start FOIA request - + )}
@@ -91,6 +110,8 @@ AgencyComponentPreview.propTypes = { onAgencySelect: PropTypes.func.isRequired, agencyComponent: PropTypes.object.isRequired, isCentralized: PropTypes.bool.isRequired, + setShowTips: PropTypes.func.isRequired, + setDestinationHref: PropTypes.func.isRequired, }; export default AgencyComponentPreview; diff --git a/js/components/agency_display.jsx b/js/components/agency_display.jsx new file mode 100644 index 00000000..e5953faa --- /dev/null +++ b/js/components/agency_display.jsx @@ -0,0 +1,210 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import AgencyComponentPreview from 'components/agency_component_preview'; +import AgencyPreview from 'components/agency_preview'; +import AgencyRequestWritingTips from 'components/agency_request_writing_tips'; +import agencyComponentStore from '../stores/agency_component'; +import { pushUrl } from '../util/use_url'; +import { titlePrefix } from '../util/dom'; +import { requestActions } from '../actions'; + +function jumpToUrl(id, type) { + const params = new URLSearchParams({ id, type }); + pushUrl(`?${params}`); +} + +/** + * Load and display an Agency or Agency Component and allow navigation + * between them. + */ +class AgencyDisplay extends Component { + constructor(props) { + super(props); + this.state = { + id: '', + type: '', + agency: null, + agencyComponent: null, + agencyComponentsForAgency: null, + notFound: false, + isCentralized: false, + showTips: false, + destinationHref: null, + }; + this.setShowTips = this.setShowTips.bind(this); + this.setDestinationHref = this.setDestinationHref.bind(this); + } + + componentDidMount() { + this.checkLoaded(); + } + + componentDidUpdate() { + this.checkLoaded(); + } + + checkLoaded() { + const { id, type, agencyFinderDataComplete } = this.props; + + if (!agencyFinderDataComplete) { + return; + } + + if (this.state.id === id && this.state.type === type) { + // Already loaded this entity + return; + } + + // Load agency or component based on the URL + try { + switch (type) { + case 'agency': { + const agency = agencyComponentStore.getAgency(id); + const agencyComponentsForAgency = agencyComponentStore.getAgencyComponentsForAgency(agency.id); + this.setStateForAgency(agency, agencyComponentsForAgency); + break; + } + case 'component': { + // Not clear why this is required, and few remember flux in 2023. + setTimeout(() => { + requestActions.fetchAgencyComponent(id) + .then(requestActions.receiveAgencyComponent) + .then(() => { + const component = agencyComponentStore.getAgencyComponent(id); + const agency = agencyComponentStore.getAgency(component.agency.id); + this.setStateForComponent(component, agency.isCentralized()); + }); + }, 0); + break; + } + default: + this.setState({ notFound: true }); + } + } catch (err) { + console.error(err); + this.setState({ notFound: true }); + } + } + + setDestinationHref(url) { + this.setState({ + destinationHref: url, + }); + } + + setShowTips(boolean) { + this.setState({ + showTips: boolean, + }); + } + + setStateForAgency(agency, agencyComponentsForAgency) { + document.title = titlePrefix + agency.name; + this.setState({ + agency, + agencyComponent: null, + agencyComponentsForAgency, + id: agency.id, + type: 'agency', + notFound: false, + }); + } + + setStateForComponent(agencyComponent, isCentralized = false) { + const title = isCentralized ? agencyComponent.agency.name : agencyComponent.title; + document.title = titlePrefix + title; + this.setState({ + agency: null, + agencyComponent, + agencyComponentsForAgency: null, + isCentralized, + id: agencyComponent.id, + type: 'component', + notFound: false, + }); + } + + render() { + // Note that the agencyComponent comes from two different sources, so the + // properties might not be consistent. + const agencyChange = (agencyComponent) => { + // We're going to push a URL so checkLoaded() can handle it + if (agencyComponent.type === 'agency_component') { + jumpToUrl(agencyComponent.id, 'component'); + return; + } + + const agency = agencyComponentStore.getAgency(agencyComponent.id); + + // Treat centralized agencies as components + if (agency && agency.isCentralized()) { + const component = this.props.agencyComponents.find((c) => c.agency.id === agency.id); + jumpToUrl(component.id, 'component'); + return; + } + + if (agency) { + jumpToUrl(agency.id, 'agency'); + } + }; + + const { + agency, agencyComponent, agencyComponentsForAgency, isCentralized, notFound, destinationHref, showTips, + } = this.state; + + const searchPath = '/agency-search.html'; + + return ( +
+

+ { + // Push URL so we don't have to reload the page. + e.preventDefault(); + pushUrl(searchPath); + }} + > + Agency Search + +

+ + {notFound && (

This agency could not be found.

)} + + { showTips ? ( + + ) : ( + <> + {agencyComponent && ( + + )} + {agency && ( + + )} + + )} +
+ ); + } +} + +AgencyDisplay.propTypes = { + agencyComponents: PropTypes.object.isRequired, + agencyFinderDataComplete: PropTypes.bool.isRequired, + type: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, +}; + +export default AgencyDisplay; diff --git a/js/components/agency_preview.jsx b/js/components/agency_preview.jsx index 234920dc..1c846e25 100644 --- a/js/components/agency_preview.jsx +++ b/js/components/agency_preview.jsx @@ -14,7 +14,7 @@ import domify from '../util/request_form/domify'; function AgencyPreview({ agency, agencyComponentsForAgency, onAgencySelect }) { const description = agency.mission(); return ( -
+

{agency.name}

@@ -49,7 +49,7 @@ function AgencyPreview({ agency, agencyComponentsForAgency, onAgencySelect }) { }
-
+
{ description && (
diff --git a/js/components/agency_request_writing_tips.jsx b/js/components/agency_request_writing_tips.jsx new file mode 100644 index 00000000..93ebe855 --- /dev/null +++ b/js/components/agency_request_writing_tips.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function AgencyRequestWritingTips(props) { + return ( +
+

Before you start your request, here are a few tips for writing your request.

+

The description of the records you are requesting is important. The scope of your request can impact how quickly an agency can respond to your request. Your description should:

+
    +
  • Be as clear and specific as possible
  • +
  • Include date ranges, if applicable
  • +
  • Provide enough detail so that the agency can determine which records are being requested and locate them with a reasonable amount of effort.
  • +
+ + Start FOIA request + +
+ ); +} + +AgencyRequestWritingTips.propTypes = { + destinationHref: PropTypes.string.isRequired, +}; + +export default AgencyRequestWritingTips; diff --git a/js/components/agency_search.jsx b/js/components/agency_search.jsx new file mode 100644 index 00000000..4df07b9f --- /dev/null +++ b/js/components/agency_search.jsx @@ -0,0 +1,244 @@ +/** + * Load /agency-search.html?-export=1 to download agencies JSON. + */ + +import React, { + useEffect, useRef, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import Pager from './foia_component_pager'; +import CardGroup from './foia_component_card_group'; +import tokenizers from '../util/tokenizers'; +import { urlParams } from '../util/wizard_helpers'; +import agencyComponentStore from '../stores/agency_component'; +import { pushUrl } from '../util/use_url'; +import { titlePrefix } from '../util/dom'; + +// Only load bloodhound in the browser (avoid loading it for tests) +let Bloodhound; +if (typeof window !== 'undefined') { + Bloodhound = require('typeahead.js/dist/bloodhound'); // eslint-disable-line global-require +} + +function AgencySearch({ + agencies, + agencyComponents, + agencyFinderDataComplete, + flatList, +}) { + useEffect(() => { + document.title = `${titlePrefix}Search for an agency`; + }, []); + + const [search, setSearch] = useState(''); + const [filteredList, setFilteredList] = useState(/** @type FlatListItem[] */ flatList); + + const isExport = urlParams().get('-export'); + const exportRef = useRef(null); + + // Store state without re-rendering. + const fakeThis = useRef({ + indexed: false, + }).current; + + // Adapted from AgencyComponentFinder + const bloodhound = useRef(new Bloodhound({ + local: [], + identify: (item) => item.id, + queryTokenizer: Bloodhound.tokenizers.whitespace, + datumTokenizer: (item) => ( + item.type === 'agency' + ? ( + // For agencies + [] + .concat(Bloodhound.tokenizers.nonword(item.name)) + .concat(Bloodhound.tokenizers.whitespace(item.abbreviation)) + ) : ( + // For agency components + [] + .concat(Bloodhound.tokenizers.nonword(item.title)) + .concat( + item.abbreviation + ? Bloodhound.tokenizers.whitespace(item.abbreviation) + : tokenizers.firstLetterOfEachCapitalizedWord(item.title), + ) + .concat(Bloodhound.tokenizers.whitespace(item.agency.name)) + .concat(Bloodhound.tokenizers.whitespace(item.agency.abbreviation)) + ) + ), + })).current; + + // Adapted from AgencyComponentFinder + useEffect(() => { + // Indexing the typeahead is expensive and if we do it in batches, it gets + // complicated to calculate which agencies are centralized vs + // decentralized. Wait until we've received all the agency finder data + // before indexing. + if (agencyFinderDataComplete) { + // Index once + if (fakeThis.indexed) { + return; + } + fakeThis.indexed = true; + + bloodhound.clear(); // Just in case + bloodhound.add(flatList); + } + }, [agencyFinderDataComplete, agencies, agencyComponents]); + + const [currentPage, setCurrentPage] = useState(1); + const cardsPerPage = 18; + + useEffect(() => { + // Scroll to top whenever page changes + try { + window.scrollTo({ top: 0, behavior: 'instant' }); + } catch (err) { + // NOOP + } + }, [currentPage]); + + useEffect(() => { + if (flatList.length) { + if (search) { + bloodhound.search(search, (filtered) => { + setFilteredList(filtered); + setCurrentPage(1); + }); + } else { + setFilteredList(flatList); + } + } + }, [flatList, search]); + + const cards = filteredList.map((flatItem) => { + const url = agencyComponentStore.getFlatItemUrl(flatItem); + return { + ...flatItem, + tag: flatItem.agency ? flatItem.agency.name : '', + url, + onClick(e) { + // Push URL so we don't have to reload the page. + e.preventDefault(); + pushUrl(url); + }, + }; + }); + + useEffect(() => { + let objectUrl; + + // Allow exporting flat item list + if (isExport && cards.length && exportRef.current) { + const json = JSON.stringify(cards.map((card) => { + const { + abbreviation, id, name, type, + } = card.agency || {}; + return { + abbreviation: card.abbreviation, + parent: card.agency ? { + abbreviation, id, name, type, + } : null, + id: card.id, + title: card.title, + type: card.type, + url: card.url, + }; + }), null, 2); + const blob = new Blob([json], { type: 'application/json' }); + objectUrl = window.URL.createObjectURL(blob); + exportRef.current.href = objectUrl; + } + + return () => { + if (objectUrl) { + window.URL.revokeObjectURL(objectUrl); + } + }; + }, [cards, exportRef.current]); + + const indexOfLastCard = currentPage * cardsPerPage; + const indexOfFirstCard = indexOfLastCard - cardsPerPage; + const currentCards = cards ? cards.slice(indexOfFirstCard, indexOfLastCard) : {}; + + function setPageAndScrollUp(page) { + setCurrentPage(page); + const el = $('#agency-search-react-app')[0]; + if (el) { + el.scrollIntoView(); + } + } + + const showPrevious = () => { + if (currentPage !== 1) { + setPageAndScrollUp(currentPage - 1); + } + }; + + const showNext = () => { + if (currentPage !== Math.ceil(cards.length / cardsPerPage)) { + setPageAndScrollUp(currentPage + 1); + } + }; + + if (isExport) { + return ( +

+ Download + {' '} + agencies.json +

+ ); + } + + return ( +
+

Search for an agency

+

+ It’s important that you identify the correct agency for your request. There are over 100 agencies and each is responsible for handling its own FOIA requests. You can start your request, learn more about an agency, or see an agency’s contact information using the search bar below. +

+ +
+ + setSearch(e.target.value)} + placeholder="Type name or keyword" + /> +
+ + {cards.length > 0 + && ( +

+ {cards.length} + {' '} + results +

+ )} + + + +
+ ); +} + +AgencySearch.propTypes = { + agencies: PropTypes.object.isRequired, + agencyComponents: PropTypes.object.isRequired, + agencyFinderDataComplete: PropTypes.bool.isRequired, + flatList: PropTypes.arrayOf(PropTypes.object).isRequired, +}; + +export default AgencySearch; diff --git a/js/components/agency_switch.jsx b/js/components/agency_switch.jsx new file mode 100644 index 00000000..3ac92f1d --- /dev/null +++ b/js/components/agency_switch.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useUrlParams } from '../util/use_url'; +import { useWait } from '../util/wizard_helpers'; +import AgencySearch from './agency_search'; +import AgencyDisplay from './agency_display'; + +/** + * Show either the agency search or display an agency. + */ +function AgencySwitch(props) { + const params = useUrlParams(); + const { agencyFinderDataComplete, agencyFinderDataProgress } = props; + + // Don't show loader until a second has passed + const { hasWaited } = useWait(1000); + + if (!agencyFinderDataComplete) { + return ( +
+ {hasWaited ? ( + <> + Loading progress: + {' '} + {agencyFinderDataProgress} + % + + ) : 'Loading...'} +
+ ); + } + + const id = params.get('id'); + const type = params.get('type'); + if (id && type) { + return ( + + ); + } + + return ( + + ); +} + +AgencySwitch.propTypes = { + agencies: PropTypes.object.isRequired, + agencyComponents: PropTypes.object.isRequired, + agencyFinderDataComplete: PropTypes.bool.isRequired, + agencyFinderDataProgress: PropTypes.number.isRequired, + flatList: PropTypes.arrayOf(PropTypes.object).isRequired, +}; + +export default AgencySwitch; diff --git a/js/components/contact_information.jsx b/js/components/contact_information.jsx index 465c600d..82b77d86 100644 --- a/js/components/contact_information.jsx +++ b/js/components/contact_information.jsx @@ -36,7 +36,7 @@ function ContactInformation({ agencyComponent }) { { agencyComponent.email && (

- { agencyComponent.email } + { agencyComponent.email }

)}
diff --git a/js/components/foia_component_card.jsx b/js/components/foia_component_card.jsx new file mode 100644 index 00000000..c5509ad2 --- /dev/null +++ b/js/components/foia_component_card.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * @param {import('prop-types').InferProps} props + */ +function Card({ card }) { + const { + tag, title, url, onClick, subtitle, confidenceScore, alt, + } = card; + + return ( + + {tag} +

{title}

+ {subtitle && {subtitle}} +
+ ); +} + +Card.propTypes = { + card: PropTypes.shape({ + tag: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + subtitle: PropTypes.string, + url: PropTypes.string.isRequired, + confidenceScore: PropTypes.string, + alt: PropTypes.bool, + onClick: PropTypes.func, + }).isRequired, +}; + +export default Card; diff --git a/js/components/foia_component_card_group.jsx b/js/components/foia_component_card_group.jsx new file mode 100644 index 00000000..7fe71ebb --- /dev/null +++ b/js/components/foia_component_card_group.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Card from './foia_component_card'; + +/** + * @param {import('prop-types').InferProps} props + */ +function CardGroup({ cardContent, alt }) { + if (cardContent && cardContent.length) { + return ( +
+
    + {cardContent.map((card) => { + card.alt = alt; + return ( +
  • + +
  • + ); + })} +
+
+ ); + } + + return null; +} + +CardGroup.propTypes = { + cardContent: PropTypes.array.isRequired, + alt: PropTypes.bool, +}; + +export default CardGroup; diff --git a/js/components/foia_component_pager.jsx b/js/components/foia_component_pager.jsx new file mode 100644 index 00000000..cc0a7b95 --- /dev/null +++ b/js/components/foia_component_pager.jsx @@ -0,0 +1,168 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function Pager({ + postsPerPage, + totalPosts, + setPage, + currentPage, + showPrevious, + showNext, +}) { + const pageNumbers = []; + + let activePageNumbers = []; + + for (let i = 1; i <= Math.ceil(totalPosts / postsPerPage); i++) { + pageNumbers.push(i); + } + + let elipsesPrev; + let elipsesNext; + + if (pageNumbers.length > 5) { + if (currentPage <= 3) { + activePageNumbers = [1, 2, 3, 4]; + } + + if (currentPage > 3) { + activePageNumbers = [ + currentPage - 1, + currentPage, + currentPage + 1, + ]; + elipsesPrev = true; + } + + if (currentPage < (pageNumbers.length - 2)) { + elipsesNext = true; + } + + if (currentPage > (pageNumbers.length - 3)) { + activePageNumbers = [ + pageNumbers.length - 3, + pageNumbers.length - 2, + pageNumbers.length - 1, + pageNumbers.length, + ]; + } + } else { + activePageNumbers = pageNumbers; + } + + return ( + + ); +} + +Pager.propTypes = { + postsPerPage: PropTypes.number, + totalPosts: PropTypes.number, + setPage: PropTypes.func, + currentPage: PropTypes.number, + showPrevious: PropTypes.func, + showNext: PropTypes.func, +}; + +export default Pager; diff --git a/js/components/foia_personnel.jsx b/js/components/foia_personnel.jsx index e33e20d1..35aec29e 100644 --- a/js/components/foia_personnel.jsx +++ b/js/components/foia_personnel.jsx @@ -18,7 +18,8 @@ function displayName(foiaPersonnel) { if (titleIsGlossaryTerm(title)) { // Highlight the title as a glossary term - title = {title}; + // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex + title = {title}; } if (name && title) { @@ -50,7 +51,7 @@ function FoiaPersonnel({ foiaPersonnel }) { { foiaPersonnel.email && (

- { email } + { email }

)}
diff --git a/js/components/landing.jsx b/js/components/landing.jsx deleted file mode 100644 index 068c2daf..00000000 --- a/js/components/landing.jsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import { requestActions } from 'actions'; -import AgencyComponentFinder from 'components/agency_component_finder'; -import AgencyComponentPreview from 'components/agency_component_preview'; -import AgencyPreview from 'components/agency_preview'; -import AgenciesByCategory from 'components/agencies_by_category'; -import AgenciesByAlphabet from 'components/agencies_by_alphabet'; -import agencyComponentStore from '../stores/agency_component'; - -class LandingComponent extends Component { - constructor(props) { - super(props); - this.state = { - agency: null, - agencyComponent: null, - agencyComponentsForAgency: null, - }; - } - - componentDidUpdate() { - if (!this.props.agencyFinderDataComplete) { - return; - } - this.consultQueryString(); - } - - setStateForAgency(agency, agencyComponentsForAgency) { - this.setState({ - agency, - agencyComponent: null, - agencyComponentsForAgency, - }); - this.props.onChangeUrlQueryParams({ - idQueryString: agency.id, - typeQueryString: 'agency', - }); - } - - setStateForComponent(agencyComponent, isCentralized = false) { - this.setState({ - agency: null, - agencyComponent, - agencyComponentsForAgency: null, - isCentralized, - }); - this.props.onChangeUrlQueryParams({ - idQueryString: agencyComponent.id, - typeQueryString: 'component', - }); - } - - consultQueryString() { - // We only want to do this one time. - if (this.queryStringConsulted) { - return; - } - this.queryStringConsulted = true; - - const { - typeQueryString, - idQueryString, - } = this.props; - - if (typeQueryString === 'agency') { - const agency = agencyComponentStore.getAgency(idQueryString); - const agencyComponentsForAgency = agencyComponentStore.getAgencyComponentsForAgency(agency.id); - this.setStateForAgency(agency, agencyComponentsForAgency); - this.scrollToAgencyFinder(); - } - if (typeQueryString === 'component') { - const component = agencyComponentStore.getAgencyComponent(idQueryString); - const agency = agencyComponentStore.getAgency(component.agency.id); - this.setStateForComponent(component, agency.isCentralized()); - this.scrollToAgencyFinder(); - } - } - - // Recursively traverse up the DOM to figure out the scroll offset - scrollOffset(element) { - return element.offsetParent - ? element.offsetTop + this.scrollOffset(element.offsetParent) - : element.offsetTop; - } - - scrollToAgencyFinder() { - window.scrollTo(0, this.scrollOffset(this.agencyFinderElement)); - } - - render() { - // Note that the agencyComponent comes from two different sources, so the - // properties might not be consistent. - const agencyChange = (agencyComponent) => { - function fetchAgencyComponent(agencyComponentId) { - return requestActions.fetchAgencyComponent(agencyComponentId) - .then(requestActions.receiveAgencyComponent) - .then(() => agencyComponentStore.getAgencyComponent(agencyComponentId)); - } - - // Scroll to back to the agency finder - this.scrollToAgencyFinder(); - - if (agencyComponent.type === 'agency_component') { - fetchAgencyComponent(agencyComponent.id) - .then((component) => this.setStateForComponent(component, false)); - return; - } - - const agency = agencyComponentStore.getAgency(agencyComponent.id); - - // Treat centralized agencies as components - if (agency.isCentralized()) { - const component = agencyComponentStore - .getState() - .agencyComponents - .find((c) => c.agency.id === agency.id); - fetchAgencyComponent(component.id) - .then((c) => this.setStateForComponent(c, true)); - return; - } - - const agencyComponentsForAgency = agencyComponentStore.getAgencyComponentsForAgency(agency.id); - this.setStateForAgency(agency, agencyComponentsForAgency); - }; - - const { - agencies, - agencyComponents, - agencyFinderDataComplete, - agencyFinderDataProgress, - } = this.props; - - return ( -
-

- Select an agency to start your request or to see an agency’s contact information: -

-
{ this.agencyFinderElement = e; }}> - -
- { - this.state.agencyComponent - && ( - - ) - } - { - this.state.agency - && ( - - ) - } - { - false - && ( - - ) - } - - { - !this.state.agencyComponent && !this.state.agency - && ( -
-

When choosing an agency

-

- Remember that some agencies can’t yet receive FOIA requests - through FOIA.gov. For those agencies, this site will provide - you with the information you need to submit a request directly to - the agency. -

-
- ) - } -
- ); - } -} - -LandingComponent.propTypes = { - agencies: PropTypes.object.isRequired, - agencyComponents: PropTypes.object.isRequired, - agencyFinderDataComplete: PropTypes.bool.isRequired, - agencyFinderDataProgress: PropTypes.number, - onChangeUrlQueryParams: PropTypes.func.isRequired, - idQueryString: PropTypes.string, - typeQueryString: PropTypes.string, -}; - -LandingComponent.defaultProps = { - agencyFinderDataProgress: 0, - idQueryString: null, - typeQueryString: null, -}; - -export default LandingComponent; diff --git a/js/components/wizard_component_back_link.jsx b/js/components/wizard_component_back_link.jsx new file mode 100644 index 00000000..361f5867 --- /dev/null +++ b/js/components/wizard_component_back_link.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * @param {import('prop-types').InferProps} props + */ +function BackLink({ text, href, onClick }) { + if (typeof href !== 'string') { + return ( + + ); + } + + return ( + + {text} + + ); +} + +BackLink.propTypes = { + text: PropTypes.string, + href: PropTypes.string, + onClick: PropTypes.func, +}; + +export default BackLink; diff --git a/js/components/wizard_component_body_text.jsx b/js/components/wizard_component_body_text.jsx new file mode 100644 index 00000000..99d7f512 --- /dev/null +++ b/js/components/wizard_component_body_text.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * @param {import('prop-types').InferProps} props + */ +function BodyText({ children }) { + return ( +

{children}

+ ); +} + +BodyText.propTypes = { + children: PropTypes.string, +}; + +export default BodyText; diff --git a/js/components/wizard_component_button.jsx b/js/components/wizard_component_button.jsx new file mode 100644 index 00000000..c4bdd626 --- /dev/null +++ b/js/components/wizard_component_button.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * @param {import('prop-types').InferProps} props + */ +function Button({ + children, + href, + isLink, + disabled, + onClick, +}) { + if (typeof href !== 'string') { + return ( + + ); + } + + return ( + + {children} + + ); +} + +Button.propTypes = { + children: PropTypes.node, + href: PropTypes.string, + isLink: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func, +}; + +export default Button; diff --git a/js/components/wizard_component_form_item.jsx b/js/components/wizard_component_form_item.jsx new file mode 100644 index 00000000..252dd1d3 --- /dev/null +++ b/js/components/wizard_component_form_item.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +let idCounter = 0; + +/** + * @param {import('prop-types').InferProps} props + */ +function FormItem({ + type, + isLabelHidden, + label, + onChange, + name, + value, + checked, + mid, + placeholder, + disabled, + maxLength, +}) { + const id = `FormItem${idCounter++}`; + let element; + + switch (type) { + case 'text': + element = ; + break; + case 'textarea': + element =