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/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(); + }); + }); +}); 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..208a7b70bc9 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -0,0 +1,240 @@ +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'; +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'; +import { + FormattedLocation, + transformKeys, + snakeCase, + LocationQuery, + LOCATIONS_URL, + PostOffice, +} from '@18f/identity-address-search'; +import { InPersonContext } from '../context'; + +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, + })); + +const requestUspsLocations = async (address: LocationQuery): Promise => { + const response = await request(LOCATIONS_URL, { + method: 'post', + json: { address: transformKeys(address, snakeCase) }, + }); + + return formatLocations(response); +}; + +function useUspsLocations() { + const [locationQuery, setLocationQuery] = useState(null); + const validatedAddressFieldRef = useRef(null); + const validatedCityFieldRef = useRef(null); + const validatedStateFieldRef = useRef(null); + const validatedZipCodeFieldRef = useRef(null); + + const handleLocationSearch = useCallback( + (event, addressValue, cityValue, stateValue, zipCodeValue) => { + 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 ( + addressValue.trim().length === 0 || + cityValue.trim().length === 0 || + stateValue.trim().length === 0 || + zipCodeValue.trim().length === 0 + ) { + return; + } + + setLocationQuery({ + address: `${addressValue}, ${cityValue}, ${stateValue} ${zipCodeValue}`, + streetAddress: addressValue, + city: cityValue, + state: stateValue, + zipCode: zipCodeValue, + }); + }, + [], + ); + + const { + data: locationResults, + isLoading: isLoadingLocations, + error: uspsError, + } = useSWR([locationQuery], ([address]) => (address ? requestUspsLocations(address) : null)); + + return { + locationQuery, + locationResults, + uspsError, + isLoading: isLoadingLocations, + handleLocationSearch, + validatedAddressFieldRef, + validatedCityFieldRef, + validatedStateFieldRef, + validatedZipCodeFieldRef, + }; +} + +interface FullAddressSearchProps { + registerField?: RegisterFieldCallback; + onFoundLocations?: ( + address: LocationQuery | null, + 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) { + const spinnerButtonRef = useRef(null); + const [addressValue, setAddressValue] = useState(''); + const [cityValue, setCityValue] = useState(''); + const [stateValue, setStateValue] = useState(''); + const [zipCodeValue, setZipCodeValue] = useState(''); + const { + locationQuery, + locationResults, + uspsError, + isLoading, + handleLocationSearch: onSearch, + validatedAddressFieldRef, + validatedCityFieldRef, + validatedStateFieldRef, + validatedZipCodeFieldRef, + } = useUspsLocations(); + + const inputChangeHandler = + (input) => + (event: React.ChangeEvent) => { + const { target } = event; + input(target.value); + }; + + const onAddressChange = inputChangeHandler(setAddressValue); + const onCityChange = inputChangeHandler(setCityValue); + const onStateChange = inputChangeHandler(setStateValue); + const onZipCodeChange = inputChangeHandler(setZipCodeValue); + + useEffect(() => { + spinnerButtonRef.current?.toggleSpinner(isLoading); + onLoadingLocations(isLoading); + }, [isLoading]); + + useEffect(() => { + uspsError && onError(uspsError); + }, [uspsError]); + + useDidUpdateEffect(() => { + onFoundLocations(locationQuery, locationResults); + }, [locationResults]); + + const handleSearch = useCallback( + (event) => { + onError(null); + onSearch(event, addressValue, cityValue, stateValue, zipCodeValue); + }, + [addressValue, cityValue, stateValue, zipCodeValue], + ); + + const { usStatesTerritories } = useContext(InPersonContext); + + return ( + <> + + + + + + + + + + {usStatesTerritories.map(([name, abbr]) => ( + + ))} + + + + + +
+ + {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.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx index 07b01579e9f..d61391e3e15 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,41 @@ 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 type { 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 { InPersonContext } from '../context'; 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,14 +48,369 @@ 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( - , - { - wrapper, - }, + + , + , + { wrapper }, ); 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.selectOptions( + 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.selectOptions( + 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.selectOptions( + 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.selectOptions( + 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.selectOptions( + 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.selectOptions( + 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.zipcode_label'), + ); + + await userEvent.click(findAllByText('in_person_proofing.body.location.location_button')[0]); + + expect(await queryByText('simple_form.required.text')).to.be.null(); + }); }); 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..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 @@ -1,14 +1,136 @@ +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 { + transformKeys, + snakeCase, + LocationQuery, + LOCATIONS_URL, +} 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'; +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 | null, + locations: FormattedLocation[] | null | undefined, + ) => { + setFoundAddress(address); + setLocationResults(locations); + }; + + // ref allows us to avoid a memory leak + 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 && ( + + )} ); 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..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 @@ -10,6 +10,8 @@ describe('InPersonOutageAlert', () => { value={{ 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 e1e2046f336..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 @@ -25,6 +25,8 @@ describe('InPersonPrepareStep', () => { value={{ inPersonOutageMessageEnabled: true, inPersonOutageExpectedUpdateDate: 'January 1, 2024', + inPersonFullAddressEntryEnabled: false, + usStatesTerritories: [], }} > @@ -36,7 +38,13 @@ 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 0e4e13eb1a5..818ada23e4c 100644 --- a/app/javascript/packages/document-capture/context/in-person.ts +++ b/app/javascript/packages/document-capture/context/in-person.ts @@ -15,10 +15,23 @@ 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; + + /** + * 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 d3dc8b6514e..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 }], @@ -95,6 +101,8 @@ const App = composeComponents( inPersonURL, inPersonOutageMessageEnabled: inPersonOutageMessageEnabled === 'true', inPersonOutageExpectedUpdateDate, + inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', + usStatesTerritories: parsedUsStatesTerritories, }, }, ], @@ -143,7 +151,6 @@ const App = composeComponents( DocumentCapture, { onStepChange: extendSession, - inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', }, ], ); 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/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:' 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..01ed1149994 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -110,6 +110,36 @@ 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 + 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')) + 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')))