Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/api/verify/document_capture_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def create
liveness_checking_enabled: liveness_checking_enabled?,
analytics: analytics,
irs_attempts_api_tracker: irs_attempts_api_tracker,
flow_path: params[:flow_path],
).submit

if result.success?
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/frontend_log_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ class FrontendLogController < ApplicationController
# rubocop:disable Layout/LineLength
EVENT_MAP = {
'IdV: verify in person troubleshooting option clicked' => :idv_verify_in_person_troubleshooting_option_clicked,
'IdV: location visited' => :idv_in_person_location_visited,
'IdV: location submitted' => :idv_in_person_location_submitted,
'IdV: prepare visited' => :idv_in_person_prepare_visited,
'IdV: prepare submitted' => :idv_in_person_prepare_submitted,
'IdV: switch_back visited' => :idv_in_person_switch_back_visited,
'IdV: switch_back submitted' => :idv_in_person_switch_back_submitted,
'IdV: forgot password visited' => :idv_forgot_password,
'IdV: password confirm visited' => :idv_review_info_visited,
'IdV: password confirm submitted' => proc do |analytics|
Expand Down
11 changes: 11 additions & 0 deletions app/javascript/packages/analytics/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SinonStub } from 'sinon';
import { trackEvent, trackError } from '@18f/identity-analytics';
import { usePropertyValue, useSandbox } from '@18f/identity-test-helpers';

Expand Down Expand Up @@ -55,6 +56,16 @@ describe('trackEvent', () => {
);
});
});

context('a network error occurs in the request', () => {
beforeEach(() => {
(window.fetch as SinonStub).rejects(new TypeError());
});

it('absorbs the error', async () => {
await trackEvent('name');
});
});
});
});

Expand Down
13 changes: 12 additions & 1 deletion app/javascript/packages/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ interface NewRelicGlobals {
*/
export async function trackEvent(event: string, payload: object = {}): Promise<void> {
const endpoint = getConfigValue('analyticsEndpoint');
if (endpoint) {
if (!endpoint) {
return;
}

try {
await window.fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, payload }),
});
} catch (error) {
// An error would only be thrown if a network error occurred during the fetch request, which is
// a scenario we can ignore. By absorbing the error, it should be assumed that an awaited call
// to `trackEvent` would never create an interrupt due to a thrown error, since an unsuccessful
// status code on the request is not an error.
//
// See: https://fetch.spec.whatwg.org/#dom-global-fetch
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ServiceProviderContextProvider,
} from '@18f/identity-document-capture';
import { FlowContext, FlowContextValue } from '@18f/identity-verify-flow';
import AnalyticsContext from '../context/analytics';
import { AnalyticsContextProvider } from '../context/analytics';
import DocumentCaptureTroubleshootingOptions from './document-capture-troubleshooting-options';
import type { ServiceProviderContext } from '../context/service-provider';

Expand Down Expand Up @@ -165,9 +165,9 @@ describe('DocumentCaptureTroubleshootingOptions', () => {
it('logs an event when clicking the troubleshooting option', async () => {
const trackEvent = sinon.stub();
const { getByRole } = render(
<AnalyticsContext.Provider value={{ trackEvent }}>
<AnalyticsContextProvider trackEvent={trackEvent}>
<DocumentCaptureTroubleshootingOptions hasErrors />
</AnalyticsContext.Provider>,
</AnalyticsContextProvider>,
{ wrapper },
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useMemo, useContext } from 'react';
import { useState, useMemo, useContext, useEffect } from 'react';
import { Alert } from '@18f/identity-components';
import { useI18n } from '@18f/identity-react-i18n';
import { FormSteps, PromptOnNavigate } from '@18f/identity-form-steps';
Expand All @@ -14,6 +14,7 @@ import InPersonSwitchBackStep from './in-person-switch-back-step';
import ReviewIssuesStep from './review-issues-step';
import ServiceProviderContext from '../context/service-provider';
import UploadContext from '../context/upload';
import AnalyticsContext from '../context/analytics';
import Submission from './submission';
import SubmissionStatus from './submission-status';
import { RetrySubmissionError } from './submission-complete';
Expand Down Expand Up @@ -58,8 +59,14 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum
const { t } = useI18n();
const serviceProvider = useContext(ServiceProviderContext);
const { flowPath } = useContext(UploadContext);
const { trackSubmitEvent, trackVisitEvent } = useContext(AnalyticsContext);
const { inPersonURL } = useContext(FlowContext);
useDidUpdateEffect(onStepChange, [stepName]);
useEffect(() => {
if (stepName) {
trackVisitEvent(stepName);
}
}, [stepName]);

/**
* Clears error state and sets form values for submission.
Expand Down Expand Up @@ -182,6 +189,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum
initialActiveErrors={initialActiveErrors}
onComplete={submitForm}
onStepChange={setStepName}
onStepSubmit={trackSubmitEvent}
autoFocus={!!submissionError}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import sinon from 'sinon';
import { useContext } from 'react';
import { render } from '@testing-library/react';
import { getAllByRole } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { useSandbox } from '@18f/identity-test-helpers';
import AnalyticsContext, { AnalyticsContextProvider } from '../context/analytics';
import InPersonLocationStep, { LOCATIONS_URL } from './in-person-location-step';

describe('InPersonLocationStep', () => {
const DEFAULT_PROPS = { toPreviousStep() {}, onChange() {}, value: {} };

const sandbox = useSandbox();

beforeEach(() => {
sandbox
.stub(window, 'fetch')
.withArgs(LOCATIONS_URL)
.resolves({
json: () => Promise.resolve([{ name: 'Baltimore' }]),
} as Response);
});

it('logs step submission with selected location', async () => {
const trackEvent = sinon.stub();
function MetadataValue() {
return <>{JSON.stringify(useContext(AnalyticsContext).submitEventMetadata)}</>;
}
const { findByText } = render(
<AnalyticsContextProvider trackEvent={trackEvent}>
<MetadataValue />
<InPersonLocationStep {...DEFAULT_PROPS} />
</AnalyticsContextProvider>,
);

const item = await findByText('Baltimore — in_person_proofing.body.location.post_office');
const button = getAllByRole(item.closest('.location-collection-item')!, 'button')[0];

await userEvent.click(button);

await findByText('{"selected_location":"Baltimore"}');
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useContext } from 'react';
import { useI18n } from '@18f/identity-react-i18n';
import { PageHeading, SpinnerDots } from '@18f/identity-components';
import BackButton from './back-button';
import LocationCollection from './location-collection';
import LocationCollectionItem from './location-collection-item';
import AnalyticsContext from '../context/analytics';

interface PostOffice {
address: string;
Expand All @@ -29,16 +30,9 @@ interface FormattedLocation {
weekdayHours: string;
}

const locationUrl = '/verify/in_person/usps_locations';
export const LOCATIONS_URL = '/verify/in_person/usps_locations';

const getResponse = async () => {
const response = await fetch(locationUrl).then((res) =>
res.json().catch((error) => {
throw error;
}),
);
return response;
};
const getResponse = () => window.fetch(LOCATIONS_URL).then((res) => res.json());

const formatLocation = (postOffices: PostOffice[]) => {
const formattedLocations = [] as FormattedLocation[];
Expand Down Expand Up @@ -79,6 +73,7 @@ function InPersonLocationStep({ onChange, toPreviousStep }) {
const [inProgress, setInProgress] = useState(false);
const [autoSubmit, setAutoSubmit] = useState(false);
const [isLoadingComplete, setIsLoadingComplete] = useState(false);
const { setSubmitEventMetadata } = useContext(AnalyticsContext);

// ref allows us to avoid a memory leak
const mountedRef = useRef(false);
Expand All @@ -93,7 +88,10 @@ function InPersonLocationStep({ onChange, toPreviousStep }) {
// useCallBack here prevents unnecessary rerenders due to changing function identity
const handleLocationSelect = useCallback(
async (e: any, id: number) => {
onChange({ selectedLocationName: locationData[id].name });
const selectedLocation = locationData[id];
const { name: selectedLocationName } = selectedLocation;
setSubmitEventMetadata({ selected_location: selectedLocationName });
onChange({ selectedLocationName });
if (autoSubmit) {
return;
}
Expand All @@ -102,15 +100,15 @@ function InPersonLocationStep({ onChange, toPreviousStep }) {
if (inProgress) {
return;
}
const selected = prepToSend(locationData[id]);
const selected = prepToSend(selectedLocation);
const headers = { 'Content-Type': 'application/json' };
const meta: HTMLMetaElement | null = document.querySelector('meta[name="csrf-token"]');
const csrf = meta?.content;
if (csrf) {
headers['X-CSRF-Token'] = csrf;
}
setInProgress(true);
await fetch(locationUrl, {
await fetch(LOCATIONS_URL, {
method: 'PUT',
body: JSON.stringify(selected),
headers,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { render } from '@testing-library/react';
import { MarketingSiteContextProvider } from '../context';
import sinon from 'sinon';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentType } from 'react';
import { FlowContext } from '@18f/identity-verify-flow';
import type { FlowContextValue } from '@18f/identity-verify-flow';
import { useSandbox } from '@18f/identity-test-helpers';
import { Provider as MarketingSiteContextProvider } from '../context/marketing-site';
import { AnalyticsContextProvider } from '../context/analytics';
import InPersonPrepareStep from './in-person-prepare-step';

describe('InPersonPrepareStep', () => {
Expand All @@ -16,19 +23,74 @@ describe('InPersonPrepareStep', () => {
).not.to.exist();
});

context('with marketing site context URL', () => {
it('renders a privacy disclaimer link', () => {
const securityAndPrivacyHowItWorksURL =
'http://example.com/security-and-privacy-how-it-works';
context('with in person URL', () => {
const inPersonURL = '#in_person';
const wrapper: ComponentType = ({ children }) => (
<FlowContext.Provider value={{ inPersonURL } as FlowContextValue}>
{children}
</FlowContext.Provider>
);

it('logs prepare step submission when clicking continue', async () => {
const trackEvent = sinon.stub();
const { getByRole } = render(
<MarketingSiteContextProvider
helpCenterRedirectURL="http://example.com/redirect/"
securityAndPrivacyHowItWorksURL={securityAndPrivacyHowItWorksURL}
>
<AnalyticsContextProvider trackEvent={trackEvent}>
<InPersonPrepareStep {...DEFAULT_PROPS} />
</MarketingSiteContextProvider>,
</AnalyticsContextProvider>,
{ wrapper },
);

await userEvent.click(getByRole('link', { name: 'forms.buttons.continue' }));
await waitFor(() => window.location.hash === inPersonURL);

expect(trackEvent).to.have.been.calledWith('IdV: prepare submitted');
});

context('when clicking in quick succession', () => {
const { clock } = useSandbox({ useFakeTimers: true });

it('logs submission only once', async () => {
const delay = 1000;
const trackEvent = sinon
.stub()
.callsFake(() => new Promise((resolve) => setTimeout(resolve, delay)));
const { getByRole } = render(
<AnalyticsContextProvider trackEvent={trackEvent}>
<InPersonPrepareStep {...DEFAULT_PROPS} />
</AnalyticsContextProvider>,
{ wrapper },
);

const link = getByRole('link', { name: 'forms.buttons.continue' });

const didFollowLinkOnFirstClick = fireEvent.click(link);
const didFollowLinkOnSecondClick = fireEvent.click(link);

clock.tick(delay);

await waitFor(() => window.location.hash === inPersonURL);

expect(didFollowLinkOnFirstClick).to.be.false();
expect(didFollowLinkOnSecondClick).to.be.false();
expect(trackEvent).to.have.been.calledOnceWith('IdV: prepare submitted');
});
});
});

context('with marketing site context URL', () => {
const securityAndPrivacyHowItWorksURL = 'http://example.com/security-and-privacy-how-it-works';
const wrapper: ComponentType = ({ children }) => (
<MarketingSiteContextProvider
helpCenterRedirectURL="http://example.com/redirect/"
securityAndPrivacyHowItWorksURL={securityAndPrivacyHowItWorksURL}
>
{children}
</MarketingSiteContextProvider>
);

it('renders a privacy disclaimer link', () => {
const { getByRole } = render(<InPersonPrepareStep {...DEFAULT_PROPS} />, { wrapper });

const link = getByRole('link', {
name: 'in_person_proofing.body.prepare.privacy_disclaimer_link links.new_window',
}) as HTMLAnchorElement;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useContext, useState } from 'react';
import type { MouseEventHandler } from 'react';
import {
Alert,
Button,
Link,
IconList,
IconListItem,
Expand All @@ -9,23 +10,37 @@ import {
ProcessListItem,
} from '@18f/identity-components';
import { removeUnloadProtection } from '@18f/identity-url';
import { useContext } from 'react';
import { FlowContext } from '@18f/identity-verify-flow';
import { getConfigValue } from '@18f/identity-config';
import { useI18n } from '@18f/identity-react-i18n';
import { FormStepsButton } from '@18f/identity-form-steps';
import { SpinnerButton } from '@18f/identity-spinner-button';
import UploadContext from '../context/upload';
import MarketingSiteContext from '../context/marketing-site';
import AnalyticsContext from '../context/analytics';
import BackButton from './back-button';
import InPersonTroubleshootingOptions from './in-person-troubleshooting-options';

function InPersonPrepareStep({ toPreviousStep, value }) {
const { t } = useI18n();
const [isSubmitting, setIsSubmitting] = useState(false);
const { inPersonURL } = useContext(FlowContext);
const { flowPath } = useContext(UploadContext);
const { trackEvent } = useContext(AnalyticsContext);
const { securityAndPrivacyHowItWorksURL } = useContext(MarketingSiteContext);
const { selectedLocationName } = value;

const onContinue: MouseEventHandler = async (event) => {
event.preventDefault();

if (!isSubmitting) {
setIsSubmitting(true);
removeUnloadProtection();
await trackEvent('IdV: prepare submitted');
window.location.href = (event.target as HTMLAnchorElement).href;
}
};

return (
<>
{selectedLocationName && (
Expand Down Expand Up @@ -90,9 +105,9 @@ function InPersonPrepareStep({ toPreviousStep, value }) {
{flowPath === 'hybrid' && <FormStepsButton.Continue />}
{inPersonURL && flowPath === 'standard' && (
<div className="margin-y-5">
<Button href={inPersonURL} onClick={removeUnloadProtection} isBig isWide>
<SpinnerButton href={inPersonURL} onClick={onContinue} isBig isWide>
{t('forms.buttons.continue')}
</Button>
</SpinnerButton>
</div>
)}
<p>
Expand Down
Loading