From aa12a1242cacef9d2125e28601fc8422e50a0d72 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Wed, 19 Jul 2023 19:26:07 -0400 Subject: [PATCH 01/12] Sloppy working search prototype --- .../in-person-full-address-search.tsx | 295 ++++++++++++++++++ ...-address-entry-post-office-search-step.tsx | 123 +++++++- 2 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 app/javascript/packages/document-capture/components/in-person-full-address-search.tsx diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx new file mode 100644 index 00000000000..ebb7cb52785 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -0,0 +1,295 @@ +import { TextInput } from '@18f/identity-components'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { t } from '@18f/identity-i18n'; +import { request } from '@18f/identity-request'; +import ValidatedField from '@18f/identity-validated-field/validated-field'; +import SpinnerButton, { SpinnerButtonRefHandle } from '@18f/identity-spinner-button/spinner-button'; +import type { RegisterFieldCallback } from '@18f/identity-form-steps'; +import useSWR from 'swr/immutable'; +import { useDidUpdateEffect } from '@18f/identity-react-hooks'; + +export const LOCATIONS_URL = new URL( + '/verify/in_person/usps_locations', + window.location.href, +).toString(); + +export interface FormattedLocation { + formattedCityStateZip: string; + distance: string; + id: number; + name: string; + saturdayHours: string; + streetAddress: string; + sundayHours: string; + weekdayHours: string; + isPilot: boolean; +} + +export interface PostOffice { + address: string; + city: string; + distance: string; + name: string; + saturday_hours: string; + state: string; + sunday_hours: string; + weekday_hours: string; + zip_code_4: string; + zip_code_5: string; + is_pilot: boolean; +} + +export interface LocationQuery { + streetAddress: string; + city: string; + state: string; + zipCode: string; + address: string; +} + +const formatLocations = (postOffices: PostOffice[]): FormattedLocation[] => + postOffices.map((po: PostOffice, index) => ({ + formattedCityStateZip: `${po.city}, ${po.state}, ${po.zip_code_5}-${po.zip_code_4}`, + id: index, + distance: po.distance, + name: po.name, + saturdayHours: po.saturday_hours, + streetAddress: po.address, + sundayHours: po.sunday_hours, + weekdayHours: po.weekday_hours, + isPilot: !!po.is_pilot, + })); + +export const snakeCase = (value: string) => + value + .split(/(?=[A-Z])/) + .join('_') + .toLowerCase(); + +// snake case the keys of the location +export const transformKeys = (location: object, predicate: (key: string) => string) => + Object.keys(location).reduce( + (acc, key) => ({ + [predicate(key)]: location[key], + ...acc, + }), + {}, + ); + +const requestUspsLocations = async (address: LocationQuery): Promise => { + const response = await request(LOCATIONS_URL, { + method: 'post', + json: { address: transformKeys(address, snakeCase) }, + }); + + return formatLocations(response); +}; + +function useUspsLocations() { + // raw text input that is set when user clicks search + const [addressQuery, setAddressQuery] = useState(null); + // todo: are these all necessary? + const validatedAddressFieldRef = useRef(null); + const validatedCityFieldRef = useRef(null); + const validatedStateFieldRef = useRef(null); + const validatedZipCodeFieldRef = useRef(null); + + const handleLocationSearch = useCallback( + (event, addressInput, cityInput, stateInput, zipCodeInput) => { + event.preventDefault(); + validatedAddressFieldRef.current?.setCustomValidity(''); + validatedAddressFieldRef.current?.reportValidity(); + validatedCityFieldRef.current?.setCustomValidity(''); + validatedCityFieldRef.current?.reportValidity(); + validatedStateFieldRef.current?.setCustomValidity(''); + validatedStateFieldRef.current?.reportValidity(); + validatedZipCodeFieldRef.current?.setCustomValidity(''); + validatedZipCodeFieldRef.current?.reportValidity(); + + // if (unvalidatedAddressInput === '') { + // return; + // } + + setAddressQuery({ + // do we need streetAddress and address? + streetAddress: addressInput, + address: addressInput, + city: cityInput, + state: stateInput, + zipCode: zipCodeInput, + }); + }, + [], + ); + + const { + data: locationResults, + isLoading: isLoadingLocations, + error: uspsError, + } = useSWR([addressQuery], ([address]) => (address ? requestUspsLocations(address) : null)); + + return { + addressQuery, + locationResults, + uspsError, + isLoading: isLoadingLocations, + handleLocationSearch, + validatedAddressFieldRef, + validatedCityFieldRef, + validatedStateFieldRef, + validatedZipCodeFieldRef, + }; +} + +interface FullAddressSearchProps { + registerField?: RegisterFieldCallback; + onFoundLocations?: ( + address: LocationQuery, + locations: FormattedLocation[] | null | undefined, + ) => void; + onLoadingLocations?: (isLoading: boolean) => void; + onError?: (error: Error | null) => void; + disabled?: boolean; +} + +function FullAddressSearch({ + registerField = () => undefined, + onFoundLocations = () => undefined, + onLoadingLocations = () => undefined, + onError = () => undefined, + disabled = false, +}: FullAddressSearchProps) { + // todo: should we get rid of verbose 'input' word? + const spinnerButtonRef = useRef(null); + const [addressInput, setAddressInput] = useState(''); + const [cityInput, setCityInput] = useState(''); + const [stateInput, setStateInput] = useState(''); + const [zipCodeInput, setZipCodeInput] = useState(''); + const { + addressQuery, + locationResults, + uspsError, + isLoading, + handleLocationSearch: onSearch, + validatedAddressFieldRef, + validatedCityFieldRef, + validatedStateFieldRef, + validatedZipCodeFieldRef, + } = useUspsLocations(); + + const textInputChangeHandler = (input) => (event: React.ChangeEvent) => { + const { target } = event; + input(target.value); + }; + + const onAddressChange = textInputChangeHandler(setAddressInput); + const onCityChange = textInputChangeHandler(setCityInput); + const onStateChange = textInputChangeHandler(setStateInput); + const onZipCodeChange = textInputChangeHandler(setZipCodeInput); + + useEffect(() => { + spinnerButtonRef.current?.toggleSpinner(isLoading); + onLoadingLocations(isLoading); + }, [isLoading]); + + useEffect(() => { + uspsError && onError(uspsError); + }, [uspsError]); + + useDidUpdateEffect(() => { + onFoundLocations(addressQuery, locationResults); + }, [locationResults]); + + const handleSearch = useCallback( + (event) => { + onError(null); + onSearch(event, addressInput, cityInput, stateInput, zipCodeInput); + }, + [addressInput, cityInput, stateInput, zipCodeInput], + ); + + return ( + <> + + + + + + + + + + + + +
+ + {t('in_person_proofing.body.location.po_search.search_button')} + +
+ + ); +} + +export default FullAddressSearch; diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx index 02466279bb5..87b609960c7 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -1,14 +1,133 @@ +import { useState, useEffect, useCallback, useRef, useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import { PageHeading } from '@18f/identity-components'; +import { Alert, PageHeading } from '@18f/identity-components'; +import { request } from '@18f/identity-request'; +import { forceRedirect } from '@18f/identity-url'; +import FullAddressSearch, { + transformKeys, + snakeCase, + LocationQuery, + LOCATIONS_URL, +} from './in-person-full-address-search'; import BackButton from './back-button'; +import AnalyticsContext from '../context/analytics'; +import InPersonLocations, { FormattedLocation } from './in-person-locations'; +import { InPersonContext } from '../context'; +import UploadContext from '../context/upload'; -function InPersonLocationFullAddressEntryPostOfficeSearchStep({ toPreviousStep }) { +function InPersonLocationFullAddressEntryPostOfficeSearchStep({ + onChange, + toPreviousStep, + registerField, +}) { + const { inPersonURL } = useContext(InPersonContext); const { t } = useI18n(); + const [inProgress, setInProgress] = useState(false); + const [isLoadingLocations, setLoadingLocations] = useState(false); + const [autoSubmit, setAutoSubmit] = useState(false); + const { trackEvent } = useContext(AnalyticsContext); + const [locationResults, setLocationResults] = useState( + null, + ); + const [foundAddress, setFoundAddress] = useState(null); + const [apiError, setApiError] = useState(null); + const [disabledAddressSearch, setDisabledAddressSearch] = useState(false); + const { flowPath } = useContext(UploadContext); + + const onFoundLocations = (address: LocationQuery, locations: FormattedLocation[]) => { + setFoundAddress(address); + setLocationResults(locations); + }; + + // ref allows us to avoid a memory leak + // todo: is this necessary? + const mountedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // useCallBack here prevents unnecessary rerenders due to changing function identity + const handleLocationSelect = useCallback( + async (e: any, id: number) => { + if (flowPath !== 'hybrid') { + e.preventDefault(); + } + const selectedLocation = locationResults![id]!; + const { streetAddress, formattedCityStateZip } = selectedLocation; + const selectedLocationAddress = `${streetAddress}, ${formattedCityStateZip}`; + onChange({ selectedLocationAddress }); + if (autoSubmit) { + setDisabledAddressSearch(true); + setTimeout(() => { + if (mountedRef.current) { + setDisabledAddressSearch(false); + } + }, 250); + return; + } + if (inProgress) { + return; + } + const selected = transformKeys(selectedLocation, snakeCase); + setInProgress(true); + try { + await request(LOCATIONS_URL, { + json: selected, + method: 'PUT', + }); + // In try block set success of request. If the request is successful, fire remaining code? + if (mountedRef.current) { + setAutoSubmit(true); + setImmediate(() => { + e.target.disabled = false; + if (flowPath !== 'hybrid') { + trackEvent('IdV: location submitted', { + selected_location: selectedLocationAddress, + }); + forceRedirect(inPersonURL!); + } + // allow process to be re-triggered in case submission did not work as expected + setAutoSubmit(false); + }); + } + } catch { + setAutoSubmit(false); + } finally { + if (mountedRef.current) { + setInProgress(false); + } + } + }, + [locationResults, inProgress], + ); return ( <> + {apiError && ( + + {t('idv.failure.exceptions.post_office_search_error')} + + )} {t('in_person_proofing.headings.po_search.location')}

{t('in_person_proofing.body.location.po_search.po_search_about')}

+ + {locationResults && foundAddress && !isLoadingLocations && ( + + )} ); From 01abf9f042b1621e5eec30f48bc4f51d7c49e647 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 10:21:43 -0400 Subject: [PATCH 02/12] Fix labels and error messages --- .../in-person-full-address-search.tsx | 41 ++++--------------- config/locales/in_person_proofing/en.yml | 4 ++ config/locales/in_person_proofing/es.yml | 4 ++ config/locales/in_person_proofing/fr.yml | 4 ++ 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx index ebb7cb52785..f3913678e1e 100644 --- a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -210,67 +210,44 @@ function FullAddressSearch({ return ( <> - + - + - + - + diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index 687a297745a..35f2574846d 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -41,8 +41,10 @@ en: inline_error: Enter a valid address with city, state, and ZIP code location_button: Select po_search: + address_label: Address address_search_hint: 'Example: 1234 N Example St., Allentown, PA 12345' address_search_label: Enter an address to find a Post Office near you + city_label: City is_searching_message: Searching for Post Office locations… none_found: Sorry, there are no participating Post Offices within 50 miles of %{address}. @@ -57,6 +59,8 @@ en: results_instructions: Select a Post Office location below, or search again using a different address. search_button: Search + state_label: State + zipcode_label: ZIP Code retail_hours_heading: Retail Hours retail_hours_sat: 'Sat:' retail_hours_sun: 'Sun:' diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index 2d1b9935bf1..d37e5cde735 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -44,9 +44,11 @@ es: inline_error: Ingrese una dirección válida que incluya ciudad, estado y código postal location_button: Seleccionar po_search: + address_label: Dirección address_search_hint: 'Ejemplo: 1234 N Example St., Allentown, PA 12345' address_search_label: Introduzca una dirección para encontrar una Oficina de Correos cercana a usted + city_label: Ciudad is_searching_message: Buscando oficinas de correos… none_found: Lo sentimos, no hay Oficinas de Correos participantes en un radio de 50 millas de la %{address}. @@ -63,6 +65,8 @@ es: results_instructions: Seleccione una ubicación de la Oficina de Correos a continuación, o busque de nuevo utilizando una dirección diferente. search_button: Buscar + state_label: Estado + zipcode_label: Código postal retail_hours_heading: Horario de atención al público retail_hours_sat: 'Sáb:' retail_hours_sun: 'Dom:' diff --git a/config/locales/in_person_proofing/fr.yml b/config/locales/in_person_proofing/fr.yml index e5bb6a14460..4e0468bd9f6 100644 --- a/config/locales/in_person_proofing/fr.yml +++ b/config/locales/in_person_proofing/fr.yml @@ -45,8 +45,10 @@ fr: inline_error: Saisissez une adresse valide avec la ville, l’état et le code postal location_button: Sélectionner po_search: + address_label: Adresse address_search_hint: 'Exemple: 1234 N Example St., Allentown, PA 12345' address_search_label: Entrez une adresse pour trouver un bureau de poste près de chez vous. + city_label: Ville is_searching_message: Recherche des emplacements de bureau de poste… none_found: Désolé, il n’y a pas de bureaux de poste participants dans un rayon de 50 miles de la ville %{address} @@ -63,6 +65,8 @@ fr: results_instructions: Sélectionnez un emplacement de bureau de poste ci-dessous, ou effectuez une nouvelle recherche en utilisant une autre adresse. search_button: Rechercher + state_label: État + zipcode_label: Code postal retail_hours_heading: Heures de vente au détail retail_hours_sat: 'Sam:' retail_hours_sun: 'Dim:' From 20b765543c413d62df74b4db3c89c09c725c3c6e Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 10:55:06 -0400 Subject: [PATCH 03/12] Don't search if fields are missing --- .../components/in-person-full-address-search.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx index f3913678e1e..74721f1ed0d 100644 --- a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -106,14 +106,13 @@ function useUspsLocations() { validatedZipCodeFieldRef.current?.setCustomValidity(''); validatedZipCodeFieldRef.current?.reportValidity(); - // if (unvalidatedAddressInput === '') { - // return; - // } + if (addressInput === '' || cityInput === '' || stateInput === '' || zipCodeInput === '') { + return; + } setAddressQuery({ - // do we need streetAddress and address? + address: `${addressInput}, ${cityInput}, ${stateInput} ${zipCodeInput}`, streetAddress: addressInput, - address: addressInput, city: cityInput, state: stateInput, zipCode: zipCodeInput, From 3858da07e9ea66530e5497d21ac2b9e402c6a8a3 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 11:29:58 -0400 Subject: [PATCH 04/12] Basic feature test --- spec/features/idv/in_person_spec.rb | 97 +++++++++++++++++++++++ spec/support/features/in_person_helper.rb | 31 ++++++++ 2 files changed, 128 insertions(+) diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 47b4f93f50b..efd31329765 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -930,4 +930,101 @@ end end end + context 'when manual address entry is enabled for post office search' do + let(:user) { user_with_2fa } + + before do + allow(IdentityConfig.store).to receive(:in_person_full_address_entry_enabled).and_return(true) + end + + it 'allows the user to search by full address', allow_browser_log: true do + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + # prepare page + complete_prepare_step(user) + + # location page + complete_full_address_location_step + + # state ID page + complete_state_id_step(user) + + # address page + complete_address_step(user) + + # ssn page + select 'Reject', from: :mock_profiling_result + complete_ssn_step(user) + + # verify page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + expect(page).to have_content(t('headings.verify')) + expect(page).to have_current_path(idv_in_person_verify_info_path) + expect(page).to have_text(InPersonHelper::GOOD_FIRST_NAME) + expect(page).to have_text(InPersonHelper::GOOD_LAST_NAME) + expect(page).to have_text(InPersonHelper::GOOD_DOB_FORMATTED_EVENT) + expect(page).to have_text(InPersonHelper::GOOD_STATE_ID_NUMBER) + expect(page).to have_text(InPersonHelper::GOOD_ADDRESS1) + expect(page).to have_text(InPersonHelper::GOOD_CITY) + expect(page).to have_text(InPersonHelper::GOOD_ZIPCODE) + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:state]) + expect(page).to have_text(DocAuthHelper::GOOD_SSN_MASKED) + complete_verify_step(user) + + # phone page + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + expect(page).to have_content(t('titles.idv.phone')) + fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone) + click_idv_send_security_code + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + fill_in_code_with_last_phone_otp + click_submit_default + + # password confirm page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) + expect(page).to have_content(t('idv.titles.session.review', app_name: APP_NAME)) + complete_review_step(user) + + # personal key page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) + expect(page).to have_content(t('titles.idv.personal_key')) + deadline = nil + freeze_time do + acknowledge_and_confirm_personal_key + deadline = (Time.zone.now + + IdentityConfig.store.in_person_enrollment_validity_in_days.days). + in_time_zone(Idv::InPerson::ReadyToVerifyPresenter::USPS_SERVER_TIMEZONE). + strftime(t('time.formats.event_date')) + end + + # ready to verify page + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.go_to_the_post_office'), + ) + expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa + enrollment_code = JSON.parse( + UspsInPersonProofing::Mock::Fixtures.request_enroll_response, + )['enrollmentCode'] + expect(page).to have_content(t('in_person_proofing.headings.barcode').tr(' ', ' ')) + expect(page).to have_content(Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code)) + expect(page).to have_content( + t('in_person_proofing.body.barcode.deadline', deadline: deadline), + ) + expect(page).to have_content('MILWAUKEE') + expect(page).to have_content('Sunday: Closed') + + # signing in again before completing in-person proofing at a post office + Capybara.reset_session! + sign_in_live_with_2fa(user) + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + end + end end diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index 769dd513f8e..cb7b884f0d8 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -110,6 +110,37 @@ def complete_location_step(_user = nil) end end + def search_for_post_office_with_full_address + expect(page).to(have_content(t('in_person_proofing.headings.po_search.location'))) + expect(page).to(have_content(t('in_person_proofing.body.location.po_search.po_search_about'))) + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.find_a_post_office')) + fill_in t('in_person_proofing.body.location.po_search.address_label'), + with: GOOD_ADDRESS1 + fill_in t('in_person_proofing.body.location.po_search.city_label'), + with: GOOD_CITY + fill_in t('in_person_proofing.body.location.po_search.state_label'), + with: GOOD_STATE + fill_in t('in_person_proofing.body.location.po_search.zipcode_label'), + with: GOOD_ZIPCODE + click_spinner_button_and_wait(t('in_person_proofing.body.location.po_search.search_button')) + expect(page).to have_css('.location-collection-item') + end + + def complete_full_address_location_step(_user = nil) + search_for_post_office_with_full_address + within first('.location-collection-item') do + click_spinner_button_and_wait t('in_person_proofing.body.location.location_button') + end + + # pause for the location list to disappear + begin + expect(page).to have_no_css('.location-collection-item') + rescue Selenium::WebDriver::Error::StaleElementReferenceError + # A StaleElementReferenceError means that the context the element + # was in has disappeared, which means the element is gone too. + end + end + def complete_prepare_step(_user = nil) expect(page).to(have_content(t('in_person_proofing.headings.prepare'))) expect(page).to(have_content(t('in_person_proofing.body.prepare.verify_step_about'))) From 4ab2691b6a8e2a9ae2445a7195368c9c9b81f551 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 11:45:10 -0400 Subject: [PATCH 05/12] Check for whitespace, not empty strings --- .../components/in-person-full-address-search.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx index 74721f1ed0d..804fa49ef8f 100644 --- a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -106,7 +106,12 @@ function useUspsLocations() { validatedZipCodeFieldRef.current?.setCustomValidity(''); validatedZipCodeFieldRef.current?.reportValidity(); - if (addressInput === '' || cityInput === '' || stateInput === '' || zipCodeInput === '') { + if ( + addressInput.trim().length === 0 || + cityInput.trim().length === 0 || + stateInput.trim().length === 0 || + zipCodeInput.trim().length === 0 + ) { return; } From c9b985acb6bd9f25a07ce415f562d334e055b337 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 12:06:41 -0400 Subject: [PATCH 06/12] Reuse exports from address-search component --- .../in-person-full-address-search.tsx | 77 ++++--------------- ...-address-entry-post-office-search-step.tsx | 6 +- 2 files changed, 17 insertions(+), 66 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx index 804fa49ef8f..01ca70d1b2f 100644 --- a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -7,45 +7,14 @@ import SpinnerButton, { SpinnerButtonRefHandle } from '@18f/identity-spinner-but import type { RegisterFieldCallback } from '@18f/identity-form-steps'; import useSWR from 'swr/immutable'; import { useDidUpdateEffect } from '@18f/identity-react-hooks'; - -export const LOCATIONS_URL = new URL( - '/verify/in_person/usps_locations', - window.location.href, -).toString(); - -export interface FormattedLocation { - formattedCityStateZip: string; - distance: string; - id: number; - name: string; - saturdayHours: string; - streetAddress: string; - sundayHours: string; - weekdayHours: string; - isPilot: boolean; -} - -export interface PostOffice { - address: string; - city: string; - distance: string; - name: string; - saturday_hours: string; - state: string; - sunday_hours: string; - weekday_hours: string; - zip_code_4: string; - zip_code_5: string; - is_pilot: boolean; -} - -export interface LocationQuery { - streetAddress: string; - city: string; - state: string; - zipCode: string; - address: string; -} +import { + FormattedLocation, + transformKeys, + snakeCase, + LocationQuery, + LOCATIONS_URL, + PostOffice, +} from '@18f/identity-address-search'; const formatLocations = (postOffices: PostOffice[]): FormattedLocation[] => postOffices.map((po: PostOffice, index) => ({ @@ -60,22 +29,6 @@ const formatLocations = (postOffices: PostOffice[]): FormattedLocation[] => isPilot: !!po.is_pilot, })); -export const snakeCase = (value: string) => - value - .split(/(?=[A-Z])/) - .join('_') - .toLowerCase(); - -// snake case the keys of the location -export const transformKeys = (location: object, predicate: (key: string) => string) => - Object.keys(location).reduce( - (acc, key) => ({ - [predicate(key)]: location[key], - ...acc, - }), - {}, - ); - const requestUspsLocations = async (address: LocationQuery): Promise => { const response = await request(LOCATIONS_URL, { method: 'post', @@ -86,9 +39,7 @@ const requestUspsLocations = async (address: LocationQuery): Promise(null); - // todo: are these all necessary? + const [locationQuery, setLocationQuery] = useState(null); const validatedAddressFieldRef = useRef(null); const validatedCityFieldRef = useRef(null); const validatedStateFieldRef = useRef(null); @@ -115,7 +66,7 @@ function useUspsLocations() { return; } - setAddressQuery({ + setLocationQuery({ address: `${addressInput}, ${cityInput}, ${stateInput} ${zipCodeInput}`, streetAddress: addressInput, city: cityInput, @@ -130,10 +81,10 @@ function useUspsLocations() { data: locationResults, isLoading: isLoadingLocations, error: uspsError, - } = useSWR([addressQuery], ([address]) => (address ? requestUspsLocations(address) : null)); + } = useSWR([locationQuery], ([address]) => (address ? requestUspsLocations(address) : null)); return { - addressQuery, + locationQuery, locationResults, uspsError, isLoading: isLoadingLocations, @@ -170,7 +121,7 @@ function FullAddressSearch({ const [stateInput, setStateInput] = useState(''); const [zipCodeInput, setZipCodeInput] = useState(''); const { - addressQuery, + locationQuery, locationResults, uspsError, isLoading, @@ -201,7 +152,7 @@ function FullAddressSearch({ }, [uspsError]); useDidUpdateEffect(() => { - onFoundLocations(addressQuery, locationResults); + onFoundLocations(locationQuery, locationResults); }, [locationResults]); const handleSearch = useCallback( diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx index 87b609960c7..08c5f3f3abc 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -3,12 +3,13 @@ import { useI18n } from '@18f/identity-react-i18n'; import { Alert, PageHeading } from '@18f/identity-components'; import { request } from '@18f/identity-request'; import { forceRedirect } from '@18f/identity-url'; -import FullAddressSearch, { +import { transformKeys, snakeCase, LocationQuery, LOCATIONS_URL, -} from './in-person-full-address-search'; +} from '@18f/identity-address-search'; +import FullAddressSearch from './in-person-full-address-search'; import BackButton from './back-button'; import AnalyticsContext from '../context/analytics'; import InPersonLocations, { FormattedLocation } from './in-person-locations'; @@ -40,7 +41,6 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ }; // ref allows us to avoid a memory leak - // todo: is this necessary? const mountedRef = useRef(false); useEffect(() => { From 94e721a787a7dc8a5bb674e17e6f5fc28e12ce6d Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 12:14:25 -0400 Subject: [PATCH 07/12] Refactor feature flag to use in person context --- .../document-capture/components/document-capture.tsx | 8 ++------ .../packages/document-capture/context/in-person.ts | 5 +++++ app/javascript/packs/document-capture.tsx | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index 40e569e1479..9920f60ad67 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -28,20 +28,16 @@ interface DocumentCaptureProps { * Callback triggered on step change. */ onStepChange?: () => void; - inPersonFullAddressEntryEnabled: Boolean; } -function DocumentCapture({ - onStepChange = () => {}, - inPersonFullAddressEntryEnabled, -}: DocumentCaptureProps) { +function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { const [formValues, setFormValues] = useState | null>(null); const [submissionError, setSubmissionError] = useState(undefined); const [stepName, setStepName] = useState(undefined); const { t } = useI18n(); const { flowPath } = useContext(UploadContext); const { trackSubmitEvent, trackVisitEvent } = useContext(AnalyticsContext); - const { inPersonURL } = useContext(InPersonContext); + const { inPersonFullAddressEntryEnabled, inPersonURL } = useContext(InPersonContext); const appName = getConfigValue('appName'); useDidUpdateEffect(onStepChange, [stepName]); diff --git a/app/javascript/packages/document-capture/context/in-person.ts b/app/javascript/packages/document-capture/context/in-person.ts index 0e4e13eb1a5..b91a4766a37 100644 --- a/app/javascript/packages/document-capture/context/in-person.ts +++ b/app/javascript/packages/document-capture/context/in-person.ts @@ -15,6 +15,11 @@ export interface InPersonContextProps { * Date communicated to users regarding expected update about their enrollment after an outage */ inPersonOutageExpectedUpdateDate?: string; + + /** + * When true users must enter a full address when searching for a Post Office location + */ + inPersonFullAddressEntryEnabled: boolean; } const InPersonContext = createContext({ diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index d3dc8b6514e..19f936a3685 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -95,6 +95,7 @@ const App = composeComponents( inPersonURL, inPersonOutageMessageEnabled: inPersonOutageMessageEnabled === 'true', inPersonOutageExpectedUpdateDate, + inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', }, }, ], @@ -143,7 +144,6 @@ const App = composeComponents( DocumentCapture, { onStepChange: extendSession, - inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', }, ], ); From 903dd978df58779462755f4e35a092d8a205bb5d Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 12:47:38 -0400 Subject: [PATCH 08/12] Fix type errors --- .../components/in-person-full-address-search.tsx | 2 +- ...n-location-full-address-entry-post-office-search-step.tsx | 5 ++++- .../components/in-person-outage-alert.spec.tsx | 1 + .../components/in-person-prepare-step.spec.tsx | 5 ++++- .../packages/document-capture/context/in-person.ts | 1 + 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx index 01ca70d1b2f..205ff2d4e9b 100644 --- a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -99,7 +99,7 @@ function useUspsLocations() { interface FullAddressSearchProps { registerField?: RegisterFieldCallback; onFoundLocations?: ( - address: LocationQuery, + address: LocationQuery | null, locations: FormattedLocation[] | null | undefined, ) => void; onLoadingLocations?: (isLoading: boolean) => void; diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx index 08c5f3f3abc..e0506317236 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -35,7 +35,10 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ const [disabledAddressSearch, setDisabledAddressSearch] = useState(false); const { flowPath } = useContext(UploadContext); - const onFoundLocations = (address: LocationQuery, locations: FormattedLocation[]) => { + const onFoundLocations = ( + address: LocationQuery | null, + locations: FormattedLocation[] | null | undefined, + ) => { setFoundAddress(address); setLocationResults(locations); }; diff --git a/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx b/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx index aa3d79760c1..f69d556b7f0 100644 --- a/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx @@ -10,6 +10,7 @@ describe('InPersonOutageAlert', () => { value={{ inPersonOutageExpectedUpdateDate: 'January 1, 2024', inPersonOutageMessageEnabled: true, + inPersonFullAddressEntryEnabled: false, }} > diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx index e1e2046f336..4c6543b8056 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx @@ -25,6 +25,7 @@ describe('InPersonPrepareStep', () => { value={{ inPersonOutageMessageEnabled: true, inPersonOutageExpectedUpdateDate: 'January 1, 2024', + inPersonFullAddressEntryEnabled: false, }} > @@ -36,7 +37,9 @@ describe('InPersonPrepareStep', () => { }); it('does not render a warning when the flag is disabled', () => { const { queryByText } = render( - + , ); diff --git a/app/javascript/packages/document-capture/context/in-person.ts b/app/javascript/packages/document-capture/context/in-person.ts index b91a4766a37..d554fae3c55 100644 --- a/app/javascript/packages/document-capture/context/in-person.ts +++ b/app/javascript/packages/document-capture/context/in-person.ts @@ -24,6 +24,7 @@ export interface InPersonContextProps { const InPersonContext = createContext({ inPersonOutageMessageEnabled: false, + inPersonFullAddressEntryEnabled: false, }); InPersonContext.displayName = 'InPersonContext'; From 5147fd316844eb5da5a711a376adf6268522eb1f Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 16:32:00 -0400 Subject: [PATCH 09/12] Add spec for full address search component --- .../in-person-full-address-search.spec.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/javascript/packages/document-capture/components/in-person-full-address-search.spec.tsx diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.spec.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.spec.tsx new file mode 100644 index 00000000000..0c698996720 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.spec.tsx @@ -0,0 +1,57 @@ +import { render } from '@testing-library/react'; +import { useSandbox } from '@18f/identity-test-helpers'; +import userEvent from '@testing-library/user-event'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import type { SetupServer } from 'msw/node'; +import { SWRConfig } from 'swr'; +import { LOCATIONS_URL } from '@18f/identity-address-search'; +import FullAddressSearch from './in-person-full-address-search'; + +describe('FullAddressSearch', () => { + const sandbox = useSandbox(); + context('when an address is found', () => { + let server: SetupServer; + before(() => { + server = setupServer( + rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + ); + server.listen(); + }); + + after(() => { + server.close(); + }); + + it('fires the callback with correct input', async () => { + const handleLocationsFound = sandbox.stub(); + const { findByText, findByLabelText } = render( + new Map() }}> + + , + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '200 main', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '17201', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + await expect(handleLocationsFound).to.eventually.be.called(); + }); + }); +}); From a8f4e2658e2b6e256c3c75af3d9cc67582b03b18 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Thu, 20 Jul 2023 16:42:45 -0400 Subject: [PATCH 10/12] Add additional specs for full address entry page --- ...ess-entry-post-office-search-step.spec.tsx | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx index 07b01579e9f..6b14bb194a9 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx @@ -1,8 +1,39 @@ import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18n } from '@18f/identity-i18n'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; import { SWRConfig } from 'swr'; +import { I18nContext } from '@18f/identity-react-i18n'; import { ComponentType } from 'react'; +import { LOCATIONS_URL } from '@18f/identity-address-search'; import InPersonLocationFullAddressEntryPostOfficeSearchStep from './in-person-location-full-address-entry-post-office-search-step'; +const USPS_RESPONSE = [ + { + address: '100 Main St E, Bronwood, Georgia, 39826', + location: { + latitude: 31.831686000000005, + longitude: -84.363768, + }, + street_address: '100 Main St E', + city: 'Bronwood', + state: 'GA', + zip_code: '39826', + }, + { + address: '200 Main St E, Bronwood, Georgia, 39826', + location: { + latitude: 32.831686000000005, + longitude: -83.363768, + }, + street_address: '200 Main St E', + city: 'Bronwood', + state: 'GA', + zip_code: '39826', + }, +]; + const DEFAULT_PROPS = { toPreviousStep() {}, onChange() {}, @@ -15,6 +46,25 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { new Map() }}>{children} ); + let server: SetupServer; + + before(() => { + server = setupServer(); + server.listen(); + }); + + after(() => { + server.close(); + }); + + beforeEach(() => { + server.resetHandlers(); + // todo: should we return USPS_RESPONSE here? + server.use( + rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + ); + }); + it('renders the step', () => { const { getByRole } = render( , @@ -25,4 +75,273 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { expect(getByRole('heading', { name: 'in_person_proofing.headings.po_search.location' })); }); + + context('USPS request returns an error', () => { + beforeEach(() => { + server.use(rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.status(500)))); + }); + + it('displays a try again error message', async () => { + const { findByText, findByLabelText } = render( + , + { wrapper }, + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '222 Merchandise Mart Plaza', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '19701', + ); + + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const error = await findByText('idv.failure.exceptions.post_office_search_error'); + expect(error).to.exist(); + }); + }); + + it('displays validation error messages to the user if fields are empty', async () => { + const { findAllByText, findByText } = render( + , + { + wrapper, + }, + ); + + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const errors = await findAllByText('simple_form.required.text'); + expect(errors).to.have.lengthOf(4); + }); + + it('displays no post office results if a successful search is followed by an unsuccessful search', async () => { + const { findByText, findByLabelText, queryByRole } = render( + , + { wrapper }, + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '222 Merchandise Mart Plaza', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '19701', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '00000', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const results = queryByRole('status', { + name: 'in_person_proofing.body.location.location_button', + }); + expect(results).not.to.exist(); + }); + + it('clicking search again after first results do not clear results', async () => { + const { findAllByText, findByText, findByLabelText } = render( + , + { wrapper }, + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '222 Merchandise Mart Plaza', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '19701', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + await findAllByText('in_person_proofing.body.location.location_button'); + }); + + it('displays correct pluralization for a single location result', async () => { + const { findByLabelText, findByText } = render( + + + , + { wrapper }, + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '222 Merchandise Mart Plaza', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '19701', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const addressQuery = '222 Merchandise Mart Plaza, Endeavor, DE 19701'; + const searchResultAlert = await findByText( + `There is one participating Post Office within 50 miles of ${addressQuery}.`, + ); + expect(searchResultAlert).to.exist(); + }); + + it('displays correct pluralization for multiple location results', async () => { + server.resetHandlers(); + server.use(rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json(USPS_RESPONSE)))); + const { findByLabelText, findByText } = render( + + + , + { wrapper }, + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '222 Merchandise Mart Plaza', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '19701', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const addressQuery = '222 Merchandise Mart Plaza, Endeavor, DE 19701'; + const searchResultAlert = await findByText( + `There are ${USPS_RESPONSE.length} participating Post Offices within 50 miles of ${addressQuery}.`, + ); + expect(searchResultAlert).to.exist(); + }); + + it('allows user to select a location', async () => { + const { findAllByText, findByLabelText, findByText, queryByText } = render( + , + { wrapper }, + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + '222 Merchandise Mart Plaza', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + 'Endeavor', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + 'DE', + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + '19701', + ); + + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + await userEvent.clear( + await findByLabelText('in_person_proofing.body.location.po_search.address_label'), + ); + await userEvent.clear( + await findByLabelText('in_person_proofing.body.location.po_search.city_label'), + ); + await userEvent.clear( + await findByLabelText('in_person_proofing.body.location.po_search.state_label'), + ); + await userEvent.clear( + await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), + ); + + await userEvent.click(findAllByText('in_person_proofing.body.location.location_button')[0]); + + expect(await queryByText('simple_form.required.text')).to.be.null(); + }); }); From 15da6a0e7987cb11012fd7d0ac8fc4489186b18e Mon Sep 17 00:00:00 2001 From: Tomas Apodaca Date: Fri, 21 Jul 2023 07:04:14 -0700 Subject: [PATCH 11/12] LG-10405: Implement state select in manual address entry form (#8825) * use select component on page w/sample options --------- Co-authored-by: Timothy Bradley --- .../in-person-full-address-search.tsx | 79 +++++++----- ...ess-entry-post-office-search-step.spec.tsx | 115 ++++++++++++++---- ...-address-entry-post-office-search-step.tsx | 2 +- .../in-person-outage-alert.spec.tsx | 1 + .../in-person-prepare-step.spec.tsx | 7 +- .../document-capture/context/in-person.ts | 7 ++ app/javascript/packs/document-capture.tsx | 7 ++ .../idv/shared/_document_capture.html.erb | 1 + spec/support/features/in_person_helper.rb | 3 +- 9 files changed, 162 insertions(+), 60 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx index 205ff2d4e9b..208a7b70bc9 100644 --- a/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -1,5 +1,5 @@ -import { TextInput } from '@18f/identity-components'; -import { useState, useRef, useEffect, useCallback } from 'react'; +import { TextInput, SelectInput } from '@18f/identity-components'; +import { useState, useRef, useEffect, useCallback, useContext } from 'react'; import { t } from '@18f/identity-i18n'; import { request } from '@18f/identity-request'; import ValidatedField from '@18f/identity-validated-field/validated-field'; @@ -15,6 +15,7 @@ import { LOCATIONS_URL, PostOffice, } from '@18f/identity-address-search'; +import { InPersonContext } from '../context'; const formatLocations = (postOffices: PostOffice[]): FormattedLocation[] => postOffices.map((po: PostOffice, index) => ({ @@ -46,7 +47,7 @@ function useUspsLocations() { const validatedZipCodeFieldRef = useRef(null); const handleLocationSearch = useCallback( - (event, addressInput, cityInput, stateInput, zipCodeInput) => { + (event, addressValue, cityValue, stateValue, zipCodeValue) => { event.preventDefault(); validatedAddressFieldRef.current?.setCustomValidity(''); validatedAddressFieldRef.current?.reportValidity(); @@ -58,20 +59,20 @@ function useUspsLocations() { validatedZipCodeFieldRef.current?.reportValidity(); if ( - addressInput.trim().length === 0 || - cityInput.trim().length === 0 || - stateInput.trim().length === 0 || - zipCodeInput.trim().length === 0 + addressValue.trim().length === 0 || + cityValue.trim().length === 0 || + stateValue.trim().length === 0 || + zipCodeValue.trim().length === 0 ) { return; } setLocationQuery({ - address: `${addressInput}, ${cityInput}, ${stateInput} ${zipCodeInput}`, - streetAddress: addressInput, - city: cityInput, - state: stateInput, - zipCode: zipCodeInput, + address: `${addressValue}, ${cityValue}, ${stateValue} ${zipCodeValue}`, + streetAddress: addressValue, + city: cityValue, + state: stateValue, + zipCode: zipCodeValue, }); }, [], @@ -114,12 +115,11 @@ function FullAddressSearch({ onError = () => undefined, disabled = false, }: FullAddressSearchProps) { - // todo: should we get rid of verbose 'input' word? const spinnerButtonRef = useRef(null); - const [addressInput, setAddressInput] = useState(''); - const [cityInput, setCityInput] = useState(''); - const [stateInput, setStateInput] = useState(''); - const [zipCodeInput, setZipCodeInput] = useState(''); + const [addressValue, setAddressValue] = useState(''); + const [cityValue, setCityValue] = useState(''); + const [stateValue, setStateValue] = useState(''); + const [zipCodeValue, setZipCodeValue] = useState(''); const { locationQuery, locationResults, @@ -132,15 +132,17 @@ function FullAddressSearch({ validatedZipCodeFieldRef, } = useUspsLocations(); - const textInputChangeHandler = (input) => (event: React.ChangeEvent) => { - const { target } = event; - input(target.value); - }; + const inputChangeHandler = + (input) => + (event: React.ChangeEvent) => { + const { target } = event; + input(target.value); + }; - const onAddressChange = textInputChangeHandler(setAddressInput); - const onCityChange = textInputChangeHandler(setCityInput); - const onStateChange = textInputChangeHandler(setStateInput); - const onZipCodeChange = textInputChangeHandler(setZipCodeInput); + const onAddressChange = inputChangeHandler(setAddressValue); + const onCityChange = inputChangeHandler(setCityValue); + const onStateChange = inputChangeHandler(setStateValue); + const onZipCodeChange = inputChangeHandler(setZipCodeValue); useEffect(() => { spinnerButtonRef.current?.toggleSpinner(isLoading); @@ -158,18 +160,20 @@ function FullAddressSearch({ const handleSearch = useCallback( (event) => { onError(null); - onSearch(event, addressInput, cityInput, stateInput, zipCodeInput); + onSearch(event, addressValue, cityValue, stateValue, zipCodeValue); }, - [addressInput, cityInput, stateInput, zipCodeInput], + [addressValue, cityValue, stateValue, zipCodeValue], ); + const { usStatesTerritories } = useContext(InPersonContext); + return ( <> - + > + + {usStatesTerritories.map(([name, abbr]) => ( + + ))} + { it('renders the step', () => { const { getByRole } = render( - , - { - wrapper, - }, + + , + , + { wrapper }, ); expect(getByRole('heading', { name: 'in_person_proofing.headings.po_search.location' })); @@ -83,7 +92,16 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays a try again error message', async () => { const { findByText, findByLabelText } = render( - , + + , + , { wrapper }, ); @@ -95,7 +113,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await findByLabelText('in_person_proofing.body.location.po_search.city_label'), 'Endeavor', ); - await userEvent.type( + await userEvent.selectOptions( await findByLabelText('in_person_proofing.body.location.po_search.state_label'), 'DE', ); @@ -115,10 +133,17 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays validation error messages to the user if fields are empty', async () => { const { findAllByText, findByText } = render( - , - { - wrapper, - }, + + , + , + { wrapper }, ); await userEvent.click( @@ -131,7 +156,16 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays no post office results if a successful search is followed by an unsuccessful search', async () => { const { findByText, findByLabelText, queryByRole } = render( - , + + , + , { wrapper }, ); @@ -143,7 +177,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await findByLabelText('in_person_proofing.body.location.po_search.city_label'), 'Endeavor', ); - await userEvent.type( + await userEvent.selectOptions( await findByLabelText('in_person_proofing.body.location.po_search.state_label'), 'DE', ); @@ -171,7 +205,16 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('clicking search again after first results do not clear results', async () => { const { findAllByText, findByText, findByLabelText } = render( - , + + , + , { wrapper }, ); @@ -183,7 +226,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await findByLabelText('in_person_proofing.body.location.po_search.city_label'), 'Endeavor', ); - await userEvent.type( + await userEvent.selectOptions( await findByLabelText('in_person_proofing.body.location.po_search.state_label'), 'DE', ); @@ -215,7 +258,17 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { }) } > - + + , + + , , { wrapper }, ); @@ -227,7 +280,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await findByLabelText('in_person_proofing.body.location.po_search.city_label'), 'Endeavor', ); - await userEvent.type( + await userEvent.selectOptions( await findByLabelText('in_person_proofing.body.location.po_search.state_label'), 'DE', ); @@ -266,7 +319,17 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { }) } > - + + , + + , , { wrapper }, ); @@ -279,7 +342,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await findByLabelText('in_person_proofing.body.location.po_search.city_label'), 'Endeavor', ); - await userEvent.type( + await userEvent.selectOptions( await findByLabelText('in_person_proofing.body.location.po_search.state_label'), 'DE', ); @@ -303,7 +366,16 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('allows user to select a location', async () => { const { findAllByText, findByLabelText, findByText, queryByText } = render( - , + + , + , { wrapper }, ); await userEvent.type( @@ -314,7 +386,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await findByLabelText('in_person_proofing.body.location.po_search.city_label'), 'Endeavor', ); - await userEvent.type( + await userEvent.selectOptions( await findByLabelText('in_person_proofing.body.location.po_search.state_label'), 'DE', ); @@ -333,9 +405,6 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { await userEvent.clear( await findByLabelText('in_person_proofing.body.location.po_search.city_label'), ); - await userEvent.clear( - await findByLabelText('in_person_proofing.body.location.po_search.state_label'), - ); await userEvent.clear( await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'), ); diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx index e0506317236..c57dbd34c93 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -128,7 +128,7 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ )} diff --git a/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx b/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx index f69d556b7f0..f8440070c7a 100644 --- a/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx @@ -11,6 +11,7 @@ describe('InPersonOutageAlert', () => { inPersonOutageExpectedUpdateDate: 'January 1, 2024', inPersonOutageMessageEnabled: true, inPersonFullAddressEntryEnabled: false, + usStatesTerritories: [], }} > diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx index 4c6543b8056..933c97aa072 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx @@ -26,6 +26,7 @@ describe('InPersonPrepareStep', () => { inPersonOutageMessageEnabled: true, inPersonOutageExpectedUpdateDate: 'January 1, 2024', inPersonFullAddressEntryEnabled: false, + usStatesTerritories: [], }} > @@ -38,7 +39,11 @@ describe('InPersonPrepareStep', () => { it('does not render a warning when the flag is disabled', () => { const { queryByText } = render( , diff --git a/app/javascript/packages/document-capture/context/in-person.ts b/app/javascript/packages/document-capture/context/in-person.ts index d554fae3c55..818ada23e4c 100644 --- a/app/javascript/packages/document-capture/context/in-person.ts +++ b/app/javascript/packages/document-capture/context/in-person.ts @@ -20,11 +20,18 @@ export interface InPersonContextProps { * When true users must enter a full address when searching for a Post Office location */ inPersonFullAddressEntryEnabled: boolean; + + /** + * Collection of US states and territories + * Each item is [Long name, abbreviation], e.g. ['Ohio', 'OH'] + */ + usStatesTerritories: Array<[string, string]>; } const InPersonContext = createContext({ inPersonOutageMessageEnabled: false, inPersonFullAddressEntryEnabled: false, + usStatesTerritories: [], }); InPersonContext.displayName = 'InPersonContext'; diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 19f936a3685..d70821ccd6e 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -83,8 +83,14 @@ const { inPersonFullAddressEntryEnabled, inPersonOutageMessageEnabled, inPersonOutageExpectedUpdateDate, + usStatesTerritories = '', } = appRoot.dataset as DOMStringMap & AppRootData; +let parsedUsStatesTerritories = []; +try { + parsedUsStatesTerritories = JSON.parse(usStatesTerritories); +} catch (e) {} + const App = composeComponents( [MarketingSiteContextProvider, { helpCenterRedirectURL, securityAndPrivacyHowItWorksURL }], [DeviceContext.Provider, { value: device }], @@ -96,6 +102,7 @@ const App = composeComponents( inPersonOutageMessageEnabled: inPersonOutageMessageEnabled === 'true', inPersonOutageExpectedUpdateDate, inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', + usStatesTerritories: parsedUsStatesTerritories, }, }, ], diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 204e8406a81..54a48fe0836 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -33,6 +33,7 @@ in_person_full_address_entry_enabled: IdentityConfig.store.in_person_full_address_entry_enabled, in_person_outage_message_enabled: IdentityConfig.store.in_person_outage_message_enabled, in_person_outage_expected_update_date: IdentityConfig.store.in_person_outage_expected_update_date, + us_states_territories: us_states_territories, } %> <%= simple_form_for( :doc_auth, diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index cb7b884f0d8..01ed1149994 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -118,8 +118,7 @@ def search_for_post_office_with_full_address with: GOOD_ADDRESS1 fill_in t('in_person_proofing.body.location.po_search.city_label'), with: GOOD_CITY - fill_in t('in_person_proofing.body.location.po_search.state_label'), - with: GOOD_STATE + select GOOD_STATE, from: t('in_person_proofing.form.state_id.identity_doc_address_state') fill_in t('in_person_proofing.body.location.po_search.zipcode_label'), with: GOOD_ZIPCODE click_spinner_button_and_wait(t('in_person_proofing.body.location.po_search.search_button')) From dfde8771cedd27cedc490b29c571477254883db9 Mon Sep 17 00:00:00 2001 From: Sheldon Bachstein Date: Fri, 21 Jul 2023 12:13:19 -0400 Subject: [PATCH 12/12] changelog: User-Facing Improvements, In-person full address entry, Add full address PO search form