diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 35ecd515860..01321adbffa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -421,12 +421,6 @@ def sp_session_request_url_with_updated_params url = if request_url.path.match?('saml') sp_session[:final_auth_request] = true complete_saml_url - elsif IdentityConfig.store.rewrite_oidc_request_prompt - # Login.gov redirects to the orginal request_url after a user authenticates - # replace prompt=login with prompt=select_account to prevent sign_out - # which should only ever occur once when the user - # lands on Login.gov with prompt=login - sp_session[:request_url]&.gsub('prompt=login', 'prompt=select_account') else sp_session[:request_url] end diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 776e12c5b4a..ded5fb07dc3 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -16,6 +16,7 @@ export { default as ProcessList } from './process-list/process-list'; export { default as ProcessListHeading } from './process-list/process-list-heading'; export { default as ProcessListItem } from './process-list/process-list-item'; export { default as ScrollIntoView } from './scroll-into-view'; +export { default as SelectInput } from './select-input'; export { default as SpinnerDots } from './spinner-dots'; export { default as StatusPage } from './status-page'; export { default as Tag } from './tag'; diff --git a/app/javascript/packages/components/select-input.spec.tsx b/app/javascript/packages/components/select-input.spec.tsx new file mode 100644 index 00000000000..a07fb071f3a --- /dev/null +++ b/app/javascript/packages/components/select-input.spec.tsx @@ -0,0 +1,94 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import { computeAccessibleDescription } from 'dom-accessibility-api'; +import SelectInput from './select-input'; + +describe('SelectInput', () => { + it('renders with an associated label', () => { + const { getByLabelText } = render(test); + + const input = getByLabelText('Input'); + + expect(input).to.be.an.instanceOf(HTMLSelectElement); + expect(input.classList.contains('usa-select')).to.be.true(); + }); + + it('renders with child elements', () => { + const childElement = ; + const { getByText, getByLabelText } = render( + {childElement}, + ); + + const input = getByLabelText('Input'); + + expect(input).to.be.an.instanceOf(HTMLSelectElement); + const optionElement = getByText('def'); + expect(optionElement).to.be.an.instanceOf(HTMLOptionElement); + expect((optionElement as HTMLOptionElement).selected).to.be.true(); + }); + + it('uses an explicitly-provided ID', () => { + const customId = 'custom-id'; + const { getByLabelText } = render( + + test + , + ); + + const input = getByLabelText('Input'); + + expect(input.id).to.equal(customId); + }); + + it('applies additional given classes', () => { + const customClass = 'custom-class'; + const { getByLabelText } = render( + + test + , + ); + + const input = getByLabelText('Input'); + + expect([...input.classList.values()]).to.have.all.members(['usa-select', customClass]); + }); + + it('applies additional input attributes', () => { + const value = 'password'; + const { getByLabelText } = render( + + test + , + ); + + const input = getByLabelText('Input'); + + expect(input.title).to.equal(value); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + test + , + ); + + expect(ref.current).to.be.an.instanceOf(HTMLSelectElement); + }); + + it('renders with a hint', () => { + const { getByLabelText, getByText } = render( + + test + , + ); + + const input = getByLabelText('Input'); + const description = computeAccessibleDescription(input); + const hint = getByText('Something special'); + + expect(description).to.equal('Something special'); + expect(hint.classList.contains('usa-hint')).to.be.true(); + }); +}); diff --git a/app/javascript/packages/components/select-input.tsx b/app/javascript/packages/components/select-input.tsx new file mode 100644 index 00000000000..3735fa44201 --- /dev/null +++ b/app/javascript/packages/components/select-input.tsx @@ -0,0 +1,64 @@ +import { forwardRef } from 'react'; +import type { InputHTMLAttributes, ForwardedRef } from 'react'; +import { useInstanceId } from '@18f/identity-react-hooks'; + +export interface SelectInputProps extends InputHTMLAttributes { + /** + * Text of label associated with input. + */ + label: string; + + /** + * Muted explainer text sitting below the label. + */ + hint?: string; + + /** + * Optional explicit ID to use in place of default behavior. + */ + id?: string; + + /** + * Additional class name to be applied to the input element. + */ + className?: string; + + /** + * Child elements + */ + children: React.ReactNode; +} + +function SelectInput( + { label, hint, id, className, children, ...inputProps }: SelectInputProps, + ref: ForwardedRef, +) { + const instanceId = useInstanceId(); + const inputId = id ?? `select-input-${instanceId}`; + const hintId = id ?? `select-input-hint-${instanceId}`; + const classes = ['usa-select', className].filter(Boolean).join(' '); + + return ( + <> + + {hint && ( +
+ {hint} +
+ )} + + + ); +} + +export default forwardRef(SelectInput); diff --git a/app/javascript/packages/document-capture/components/acuant-camera.tsx b/app/javascript/packages/document-capture/components/acuant-camera.tsx index cbdc2243d43..b6707d54eaa 100644 --- a/app/javascript/packages/document-capture/components/acuant-camera.tsx +++ b/app/javascript/packages/document-capture/components/acuant-camera.tsx @@ -93,14 +93,11 @@ interface AcuantCameraUIOptions { text: AcuantCameraUIText; } -/** - * Document type. - * - * 0 = None - * 1 = ID - * 2 = Passport - */ -export type AcuantDocumentType = 0 | 1 | 2; +export enum AcuantDocumentType { + NONE = 0, + ID = 1, + PASSPORT = 2, +} export type AcuantCaptureFailureError = | undefined // Cropping failure (SDK v11.5.0, L1171) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index dcc7540660b..acbe5ccd9e5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -30,7 +30,7 @@ import type { } from './acuant-camera'; type AcuantDocumentTypeLabel = 'id' | 'passport' | 'none'; -type AcuantImageAssessment = 'success' | 'glare' | 'blurry'; +type AcuantImageAssessment = 'success' | 'glare' | 'blurry' | 'unsupported'; type ImageSource = 'acuant' | 'upload'; interface ImageAnalyticsPayload { @@ -438,15 +438,19 @@ function AcuantCapture( const { image, cardtype, dpi, moire, glare, sharpness } = nextCapture; const isAssessedAsGlare = glare < glareThreshold; const isAssessedAsBlurry = sharpness < sharpnessThreshold; + const isAssessedAsUnsupported = cardtype !== 1; const { width, height, data } = image; let assessment: AcuantImageAssessment; - if (isAssessedAsGlare) { - setOwnErrorMessage(t('doc_auth.errors.glare.failed_short')); - assessment = 'glare'; - } else if (isAssessedAsBlurry) { + if (isAssessedAsBlurry) { setOwnErrorMessage(t('doc_auth.errors.sharpness.failed_short')); assessment = 'blurry'; + } else if (isAssessedAsGlare) { + setOwnErrorMessage(t('doc_auth.errors.glare.failed_short')); + assessment = 'glare'; + } else if (isAssessedAsUnsupported) { + setOwnErrorMessage(t('doc_auth.errors.card_type')); + assessment = 'unsupported'; } else { assessment = 'success'; } @@ -456,6 +460,7 @@ function AcuantCapture( height, mimeType: 'image/jpeg', // Acuant Web SDK currently encodes all images as JPEG source: 'acuant', + isAssessedAsUnsupported, documentType: getDocumentTypeLabel(cardtype), dpi, moire, @@ -475,7 +480,11 @@ function AcuantCapture( onChangeAndResetError(data, analyticsPayload); onResetFailedCaptureAttempts(); } else { - onFailedCaptureAttempt({ isAssessedAsGlare, isAssessedAsBlurry }); + onFailedCaptureAttempt({ + isAssessedAsGlare, + isAssessedAsBlurry, + isAssessedAsUnsupported, + }); } setIsCapturingEnvironment(false); diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index d7b5c94b15e..9920f60ad67 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -10,6 +10,7 @@ import { UploadFormEntriesError } from '../services/upload'; import DocumentsStep from './documents-step'; import InPersonPrepareStep from './in-person-prepare-step'; import InPersonLocationPostOfficeSearchStep from './in-person-location-post-office-search-step'; +import InPersonLocationFullAddressEntryPostOfficeSearchStep from './in-person-location-full-address-entry-post-office-search-step'; import InPersonSwitchBackStep from './in-person-switch-back-step'; import ReviewIssuesStep from './review-issues-step'; import UploadContext from '../context/upload'; @@ -36,7 +37,7 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { 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]); @@ -78,6 +79,10 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { initialValues = formValues; } + const inPersonLocationPostOfficeSearchForm = inPersonFullAddressEntryEnabled + ? InPersonLocationFullAddressEntryPostOfficeSearchStep + : InPersonLocationPostOfficeSearchStep; + const inPersonSteps: FormStep[] = inPersonURL === undefined ? [] @@ -89,7 +94,7 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { }, { name: 'location', - form: InPersonLocationPostOfficeSearchStep, + form: inPersonLocationPostOfficeSearchForm, title: t('in_person_proofing.headings.po_search.location'), }, flowPath === 'hybrid' && { 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..6af8df3d7a3 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-full-address-search.tsx @@ -0,0 +1,239 @@ +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, 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 ( + addressInput.trim().length === 0 || + cityInput.trim().length === 0 || + stateInput.trim().length === 0 || + zipCodeInput.trim().length === 0 + ) { + return; + } + + setLocationQuery({ + address: `${addressInput}, ${cityInput}, ${stateInput} ${zipCodeInput}`, + streetAddress: addressInput, + city: cityInput, + state: stateInput, + zipCode: zipCodeInput, + }); + }, + [], + ); + + 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) { + // 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 { + 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(setAddressInput); + const onCityChange = inputChangeHandler(setCityInput); + const onStateChange = inputChangeHandler(setStateInput); + const onZipCodeChange = inputChangeHandler(setZipCodeInput); + + 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, addressInput, cityInput, stateInput, zipCodeInput); + }, + [addressInput, cityInput, stateInput, zipCodeInput], + ); + + 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 new file mode 100644 index 00000000000..07b01579e9f --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import { ComponentType } from 'react'; +import InPersonLocationFullAddressEntryPostOfficeSearchStep from './in-person-location-full-address-entry-post-office-search-step'; + +const DEFAULT_PROPS = { + toPreviousStep() {}, + onChange() {}, + value: {}, + registerField() {}, +}; + +describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { + const wrapper: ComponentType = ({ children }) => ( + new Map() }}>{children} + ); + + it('renders the step', () => { + const { getByRole } = render( + , + { + wrapper, + }, + ); + + expect(getByRole('heading', { name: 'in_person_proofing.headings.po_search.location' })); + }); +}); 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 new file mode 100644 index 00000000000..e0506317236 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect, useCallback, useRef, useContext } from 'react'; +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 { + 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({ + 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 && ( + + )} + + + ); +} + +export default InPersonLocationFullAddressEntryPostOfficeSearchStep; diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx index 1ecd45e168c..fef6d77cea7 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx @@ -56,7 +56,7 @@ const DEFAULT_PROPS = { registerField() {}, }; -describe('InPersonPostOfficeSearchStep', () => { +describe('InPersonLocationPostOfficeSearchStep', () => { const wrapper: ComponentType = ({ children }) => ( new Map() }}>{children} ); 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..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/components/po-search-fallback-select.scss b/app/javascript/packages/document-capture/components/po-search-fallback-select.scss new file mode 100644 index 00000000000..a3a27c2df8f --- /dev/null +++ b/app/javascript/packages/document-capture/components/po-search-fallback-select.scss @@ -0,0 +1,3 @@ +.usa-input--error.usa-input--error.usa-input--error { + border-color: #e21c3d; +} \ No newline at end of file diff --git a/app/javascript/packages/document-capture/components/select-error.scss b/app/javascript/packages/document-capture/components/select-error.scss new file mode 100644 index 00000000000..361d2549a72 --- /dev/null +++ b/app/javascript/packages/document-capture/components/select-error.scss @@ -0,0 +1,3 @@ +.usa-input--error.usa-input--error.usa-input--error { + border-color: #e21c3d; +} diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx index 12d300bd0a5..b55935a7091 100644 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx @@ -5,6 +5,7 @@ import useCounter from '../hooks/use-counter'; interface CaptureAttemptMetadata { isAssessedAsGlare: boolean; isAssessedAsBlurry: boolean; + isAssessedAsUnsupported: boolean; } interface FailedCaptureAttemptsContextInterface { @@ -66,6 +67,7 @@ interface FailedCaptureAttemptsContextInterface { const DEFAULT_LAST_ATTEMPT_METADATA: CaptureAttemptMetadata = { isAssessedAsGlare: false, isAssessedAsBlurry: false, + isAssessedAsUnsupported: false, }; const FailedCaptureAttemptsContext = createContext({ 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/packages/document-capture/styles.scss b/app/javascript/packages/document-capture/styles.scss index 1bf22932693..7f5a3f89355 100644 --- a/app/javascript/packages/document-capture/styles.scss +++ b/app/javascript/packages/document-capture/styles.scss @@ -1,3 +1,4 @@ @import './components/acuant-capture'; @import './components/acuant-capture-canvas'; @import './components/location-collection-item'; +@import './components/select-error'; diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index c1ac8ac689f..497b3e9ce56 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -31,7 +31,7 @@ interface FormStepRegisterFieldOptions { export type RegisterFieldCallback = ( field: string, options?: Partial, -) => undefined | RefCallback; +) => undefined | RefCallback; export type OnErrorCallback = (error: Error, options?: { field?: string | null }) => void; diff --git a/app/javascript/packages/validated-field/validated-field.spec.tsx b/app/javascript/packages/validated-field/validated-field.spec.tsx index a292746490d..ad4ce5d1f68 100644 --- a/app/javascript/packages/validated-field/validated-field.spec.tsx +++ b/app/javascript/packages/validated-field/validated-field.spec.tsx @@ -162,6 +162,24 @@ describe('ValidatedField', () => { expect(input.validationMessage).to.equal('oops'); }); + it('validates using validate prop with select element', () => { + const validate = sinon.stub().throws(new Error('oops')); + const { getByRole } = render( + + + , + ); + + const input = getByRole('combobox') as HTMLSelectElement; + + expect(input.checkValidity()).to.be.false(); + expect(input.validationMessage).to.equal('oops'); + }); + it('merges classNames', () => { const { getByRole } = render( diff --git a/app/javascript/packages/validated-field/validated-field.tsx b/app/javascript/packages/validated-field/validated-field.tsx index d03bc9a6f77..497c1f976fe 100644 --- a/app/javascript/packages/validated-field/validated-field.tsx +++ b/app/javascript/packages/validated-field/validated-field.tsx @@ -69,13 +69,13 @@ export function getErrorMessages(inputType?: string) { return messages; } -function ValidatedField( +function ValidatedField( { validate = () => {}, messages, children, ...inputProps - }: ValidatedFieldProps & InputHTMLAttributes, + }: ValidatedFieldProps & InputHTMLAttributes, forwardedRef, ) { const fieldRef = useRef(); @@ -97,11 +97,19 @@ function ValidatedField( nextError = nextError || (input.validity.customError && input.validationMessage) || ''; input.setCustomValidity(nextError); - return !nextError && HTMLInputElement.prototype.checkValidity.call(input); + return ( + !nextError && + (input instanceof HTMLSelectElement + ? HTMLSelectElement.prototype.checkValidity.call(input) + : HTMLInputElement.prototype.checkValidity.call(input)) + ); }; input.reportValidity = () => { input.checkValidity(); + if (input instanceof HTMLSelectElement) { + return HTMLSelectElement.prototype.reportValidity.call(input); + } return HTMLInputElement.prototype.reportValidity.call(input); }; } @@ -109,8 +117,8 @@ function ValidatedField( const errorId = `validated-field-error-${instanceId}`; - const input: ReactHTMLElement = children - ? (Children.only(children) as ReactHTMLElement) + const input: ReactHTMLElement = children + ? (Children.only(children) as ReactHTMLElement) : createElement('input'); const inputClasses = ['validated-field__input', inputProps.className, input.props.className] diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 25d84ba0f64..d70821ccd6e 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -80,10 +80,17 @@ const { cancelUrl: cancelURL, idvInPersonUrl: inPersonURL, securityAndPrivacyHowItWorksUrl: securityAndPrivacyHowItWorksURL, + 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 }], @@ -94,6 +101,8 @@ const App = composeComponents( inPersonURL, inPersonOutageMessageEnabled: inPersonOutageMessageEnabled === 'true', inPersonOutageExpectedUpdateDate, + inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', + usStatesTerritories: parsedUsStatesTerritories, }, }, ], @@ -138,7 +147,12 @@ const App = composeComponents( maxSubmissionAttemptsBeforeNativeCamera: Number(maxSubmissionAttemptsBeforeNativeCamera), }, ], - [DocumentCapture, { onStepChange: extendSession }], + [ + DocumentCapture, + { + onStepChange: extendSession, + }, + ], ); render(, appRoot); diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 662c6ff5973..54a48fe0836 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -30,8 +30,10 @@ failure_to_proof_url: failure_to_proof_url, idv_in_person_url: (IdentityConfig.store.in_person_doc_auth_button_enabled && Idv::InPersonConfig.enabled_for_issuer?(decorated_session.sp_issuer)) ? idv_in_person_url : nil, security_and_privacy_how_it_works_url: MarketingSite.security_and_privacy_how_it_works_url, + 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/application.yml.default b/config/application.yml.default index d4450e14df5..529d15b7716 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -67,9 +67,6 @@ broken_personal_key_window_start: '2021-07-29T00:00:00Z' broken_personal_key_window_finish: '2021-09-22T00:00:00Z' component_previews_enabled: false country_phone_number_overrides: '{}' -doc_auth_error_dpi_threshold: 290 -doc_auth_error_sharpness_threshold: 40 -doc_auth_error_glare_threshold: 40 database_pool_extra_connections_for_worker: 4 database_pool_idp: 5 database_socket: '' @@ -153,6 +150,7 @@ in_person_enrollments_ready_job_wait_time_seconds: 20 in_person_results_delay_in_hours: 1 in_person_ssn_info_controller_enabled: false in_person_completion_survey_url: 'https://login.gov' +in_person_full_address_entry_enabled: false in_person_outage_message_enabled: false # in_person_outage_expected_update_date and in_person_outage_emailed_by_date below # are strings in the format 'Month day, year' @@ -287,7 +285,6 @@ requests_per_ip_period: 300 requests_per_ip_track_only_mode: false reset_password_email_max_attempts: 20 reset_password_email_window_in_minutes: 60 -rewrite_oidc_request_prompt: true risc_notifications_local_enabled: false risc_notifications_active_job_enabled: false risc_notifications_rate_limit_interval: 60 diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index c91745ff7db..c5f00f01cba 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -52,6 +52,7 @@ en: browser or system settings, reload this page, or upload a photo instead. failed: Camera failed to start, please try again. + card_type: Try again with your driver’s license or state ID card. dpi: failed_short: Image is too small or blurry, please try again. top_msg: We couldn’t read your ID. Your image size may be too small, or your ID diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index c741fd81ca6..7c8caa16b9a 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -62,6 +62,8 @@ es: compruebe la configuración de su navegador o sistema, recargue esta página o suba una foto en su lugar. failed: No se ha podido encender la cámara, por favor, inténtelo de nuevo. + card_type: Solo se aceptan licencias de conducir o documentos de identidad + estatales. dpi: failed_short: La imagen es demasiado pequeña o está borrosa, por favor inténtelo de nuevo. diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 24ff5d1f60a..cedab5fca79 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -67,6 +67,8 @@ fr: Veuillez vérifier les paramètres de votre navigateur ou de votre système, recharger cette page ou télécharger une photo à la place. failed: L’appareil photo n’a pas réussi à démarrer, veuillez réessayer. + card_type: Réessayez avec votre permis de conduire ou carte d’identité délivrée + par l’État. dpi: failed_short: L’image est trop petite ou floue, veuillez réessayer. top_msg: Nous n’avons pas pu lire votre pièce d’identité. La taille de votre 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/lib/identity_config.rb b/lib/identity_config.rb index 4f7ee430a3c..2b122bb88a3 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -243,6 +243,7 @@ def self.build_store(config_map) config.add(:in_person_results_delay_in_hours, type: :integer) config.add(:in_person_ssn_info_controller_enabled, type: :boolean) config.add(:in_person_completion_survey_url, type: :string) + config.add(:in_person_full_address_entry_enabled, type: :boolean) config.add(:in_person_outage_message_enabled, type: :boolean) config.add(:in_person_outage_expected_update_date, type: :string) config.add(:in_person_outage_emailed_by_date, type: :string) @@ -390,7 +391,6 @@ def self.build_store(config_map) config.add(:requests_per_ip_track_only_mode, type: :boolean) config.add(:reset_password_email_max_attempts, type: :integer) config.add(:reset_password_email_window_in_minutes, type: :integer) - config.add(:rewrite_oidc_request_prompt, type: :boolean) config.add(:risc_notifications_local_enabled, type: :boolean) config.add(:risc_notifications_active_job_enabled, type: :boolean) config.add(:risc_notifications_rate_limit_interval, type: :integer) diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 197aededbac..8c8bca397ff 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -476,13 +476,6 @@ def index end end - context 'with a url that has prompt=login' do - let(:sp_session_request_url) { '/authorize?prompt=login' } - it 'changes it to prompt=select_account' do - expect(url_with_updated_params).to eq('/authorize?prompt=select_account') - end - end - context 'when the locale has been changed' do before { I18n.locale = :es } let(:sp_session_request_url) { '/authorize' } 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/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 3149846a8d6..c55cb3619fe 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -24,12 +24,7 @@ expect(certs_response[:keys].find { |key| key[:kid] == kid }).to be end - context 'with client_secret_jwt and disabling rewrite_oidc_request_prompt' do - before do - allow(IdentityConfig.store).to receive(:rewrite_oidc_request_prompt). - and_return(false) - end - + context 'with client_secret_jwt' do it 'succeeds with prompt login and no prior session' do oidc_end_client_secret_jwt(prompt: 'login') end diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index 96bf37f3063..320abd0c920 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -547,6 +547,47 @@ describe('document-capture/components/acuant-capture', () => { ); }); + it('renders error message and logs metadata if capture succeeds but the document type identified is unsupported', async () => { + const trackEvent = sinon.spy(); + const { getByText, findByText } = render( + + + + + + + , + ); + + initialize({ + start: sinon.stub().callsFake(async (callbacks) => { + await Promise.resolve(); + callbacks.onCaptured(); + await Promise.resolve(); + callbacks.onCropped({ + ...ACUANT_CAPTURE_SUCCESS_RESULT, + cardtype: 2, + }); + }), + }); + + const button = getByText('doc_auth.buttons.take_picture'); + fireEvent.click(button); + + const error = await findByText('doc_auth.errors.card_type'); + + expect(trackEvent).to.have.been.calledWith( + 'IdV: test image added', + sinon.match({ + documentType: 'passport', + isAssessedAsUnsupported: true, + assessment: 'unsupported', + }), + ); + + expect(error).to.be.ok(); + }); + it('renders error message if capture succeeds but photo glare exceeds threshold', async () => { const trackEvent = sinon.spy(); const { getByText, findByText } = render( @@ -585,6 +626,7 @@ describe('document-capture/components/acuant-capture', () => { height: 1104, sharpnessScoreThreshold: sinon.match.number, glareScoreThreshold: 50, + isAssessedAsUnsupported: false, isAssessedAsBlurry: false, isAssessedAsGlare: true, assessment: 'glare', @@ -639,6 +681,7 @@ describe('document-capture/components/acuant-capture', () => { height: 1104, sharpnessScoreThreshold: 50, glareScoreThreshold: sinon.match.number, + isAssessedAsUnsupported: false, isAssessedAsBlurry: true, isAssessedAsGlare: false, assessment: 'blurry', @@ -746,6 +789,7 @@ describe('document-capture/components/acuant-capture', () => { height: 1104, sharpnessScoreThreshold: 50, glareScoreThreshold: sinon.match.number, + isAssessedAsUnsupported: false, isAssessedAsBlurry: true, isAssessedAsGlare: false, assessment: 'blurry', diff --git a/spec/javascript/packages/document-capture/components/capture-troubleshooting-spec.jsx b/spec/javascript/packages/document-capture/components/capture-troubleshooting-spec.jsx index 35060c7313a..10e6e9b8419 100644 --- a/spec/javascript/packages/document-capture/components/capture-troubleshooting-spec.jsx +++ b/spec/javascript/packages/document-capture/components/capture-troubleshooting-spec.jsx @@ -92,6 +92,7 @@ describe('document-capture/context/capture-troubleshooting', () => { expect(trackEvent).to.have.been.calledWith('IdV: Capture troubleshooting shown', { isAssessedAsGlare: false, isAssessedAsBlurry: false, + isAssessedAsUnsupported: false, }); const tryAgainButton = getByRole('button', { name: 'idv.failure.button.warning' }); diff --git a/spec/javascript/packages/document-capture/components/document-capture-spec.jsx b/spec/javascript/packages/document-capture/components/document-capture-spec.jsx index 8f681bd89e7..c685a5dc62c 100644 --- a/spec/javascript/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/document-capture-spec.jsx @@ -91,6 +91,7 @@ describe('document-capture/components/document-capture', () => { image: { data: validUpload, }, + cardtype: 1, }); }); diff --git a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx index e1d5adf102a..0a289f0386a 100644 --- a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx @@ -66,7 +66,7 @@ describe('document-capture/components/documents-step', () => { ); initialize(); - const result = { sharpness: 100, image: { data: '' } }; + const result = { sharpness: 100, image: { data: '' }, cardtype: 1 }; window.AcuantCameraUI.start.callsFake(({ onCropped }) => onCropped({ ...result, glare: 10 })); await userEvent.click(getByLabelText('doc_auth.headings.document_capture_front')); 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')))