From 0b62c87c8d948a9953a61241920f1f3026627c60 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 6 Sep 2022 12:54:13 -0400 Subject: [PATCH 01/12] Add missing flow_path parameter for API::DocumentCaptureController So that it's logged correctly in analytics --- app/controllers/api/verify/document_capture_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/api/verify/document_capture_controller.rb b/app/controllers/api/verify/document_capture_controller.rb index 9af779eab05..276b4b474e7 100644 --- a/app/controllers/api/verify/document_capture_controller.rb +++ b/app/controllers/api/verify/document_capture_controller.rb @@ -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? From d76bee1d07bc275965d0179490c0f9e7cb8af016 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 6 Sep 2022 12:55:27 -0400 Subject: [PATCH 02/12] LG-7205: Add logging for initial step visits, submissions **Why**: So that we have better insight into the user's journey through the in-person proofing flow. changelog: Upcoming Features, In-person proofing, Improve analytics for in-person proofing actions --- app/controllers/frontend_log_controller.rb | 6 ++ .../components/document-capture.tsx | 16 +++-- .../components/in-person-location-step.tsx | 2 + .../in-person-prepare-step.spec.tsx | 53 +++++++++++--- .../components/in-person-prepare-step.tsx | 23 +++++- .../components/in-person-switch-back-step.tsx | 2 + .../hooks/use-step-logger.spec.tsx | 53 ++++++++++++++ .../document-capture/hooks/use-step-logger.ts | 34 +++++++++ app/services/analytics_events.rb | 36 ++++++++++ spec/features/idv/analytics_spec.rb | 70 +++++++++++++++++++ 10 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx create mode 100644 app/javascript/packages/document-capture/hooks/use-step-logger.ts diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index 1a36d8e6343..24cd1b27f5f 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -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| diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index 4821da847fd..a4bcf01b7c1 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -21,6 +21,7 @@ import { BackgroundEncryptedUploadError } from '../higher-order/with-background- import SuspenseErrorBoundary from './suspense-error-boundary'; import SubmissionInterstitial from './submission-interstitial'; import withProps from '../higher-order/with-props'; +import useStepLogger from '../hooks/use-step-logger'; /** * Returns a new object with specified keys removed. @@ -59,6 +60,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum const serviceProvider = useContext(ServiceProviderContext); const { flowPath } = useContext(UploadContext); const { inPersonURL } = useContext(FlowContext); + const { onStepSubmit } = useStepLogger(stepName); useDidUpdateEffect(onStepChange, [stepName]); /** @@ -104,15 +106,15 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum ? [] : ([ { - name: 'location', + name: InPersonLocationStep.stepName, form: InPersonLocationStep, }, { - name: 'prepare', + name: InPersonPrepareStep.stepName, form: InPersonPrepareStep, }, flowPath === 'hybrid' && { - name: 'switch_back', + name: InPersonSwitchBackStep.stepName, form: InPersonSwitchBackStep, }, ].filter(Boolean) as FormStep[]); @@ -146,7 +148,12 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum ].filter(Boolean) as FormStep[]); const stepIndicatorPath = - stepName && ['location', 'prepare', 'switch_back'].includes(stepName) + stepName && + [ + InPersonLocationStep.stepName, + InPersonPrepareStep.stepName, + InPersonSwitchBackStep.stepName, + ].includes(stepName) ? VerifyFlowPath.IN_PERSON : VerifyFlowPath.DEFAULT; @@ -182,6 +189,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum initialActiveErrors={initialActiveErrors} onComplete={submitForm} onStepChange={setStepName} + onStepSubmit={onStepSubmit} autoFocus={!!submissionError} /> diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.tsx index 98d8e9824fd..a25c0831ac9 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.tsx @@ -192,4 +192,6 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { ); } +InPersonLocationStep.stepName = 'location'; + export default InPersonLocationStep; 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 2600ecc76e6..490cfb73bd1 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 @@ -1,5 +1,11 @@ -import { render } from '@testing-library/react'; -import { MarketingSiteContextProvider } from '../context'; +import sinon from 'sinon'; +import { 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 { Provider as MarketingSiteContextProvider } from '../context/marketing-site'; +import AnalyticsContext from '../context/analytics'; import InPersonPrepareStep from './in-person-prepare-step'; describe('InPersonPrepareStep', () => { @@ -16,19 +22,44 @@ 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 }) => ( + + {children} + + ); + + it('logs prepare step submission when clicking continue', async () => { + const trackEvent = sinon.stub(); const { getByRole } = render( - + - , + , + { 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('with marketing site context URL', () => { + const securityAndPrivacyHowItWorksURL = 'http://example.com/security-and-privacy-how-it-works'; + const wrapper: ComponentType = ({ children }) => ( + + {children} + + ); + + it('renders a privacy disclaimer link', () => { + const { getByRole } = render(, { wrapper }); + const link = getByRole('link', { name: 'in_person_proofing.body.prepare.privacy_disclaimer_link links.new_window', }) as HTMLAnchorElement; diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx index 4a0581f7db5..5f8f8be60b7 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx @@ -1,3 +1,4 @@ +import type { MouseEventHandler } from 'react'; import { Alert, Button, @@ -16,6 +17,7 @@ import { useI18n } from '@18f/identity-react-i18n'; import { FormStepsButton } from '@18f/identity-form-steps'; 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'; @@ -23,9 +25,26 @@ function InPersonPrepareStep({ toPreviousStep, value }) { const { t } = useI18n(); const { inPersonURL } = useContext(FlowContext); const { flowPath } = useContext(UploadContext); + const { trackEvent } = useContext(AnalyticsContext); const { securityAndPrivacyHowItWorksURL } = useContext(MarketingSiteContext); const { selectedLocationName } = value; + const onContinue: MouseEventHandler = async (event) => { + removeUnloadProtection(); + + let destination; + if (event.target instanceof HTMLAnchorElement) { + event.preventDefault(); + destination = event.target.href; + } + + await trackEvent('IdV: prepare submitted'); + + if (destination) { + window.location = destination; + } + }; + return ( <> {selectedLocationName && ( @@ -90,7 +109,7 @@ function InPersonPrepareStep({ toPreviousStep, value }) { {flowPath === 'hybrid' && } {inPersonURL && flowPath === 'standard' && (
-
@@ -114,4 +133,6 @@ function InPersonPrepareStep({ toPreviousStep, value }) { ); } +InPersonPrepareStep.stepName = 'prepare'; + export default InPersonPrepareStep; diff --git a/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx b/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx index bcf11f094c0..004a318ffba 100644 --- a/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx @@ -23,4 +23,6 @@ function InPersonSwitchBackStep({ onChange }: FormStepComponentProps) { ); } +InPersonSwitchBackStep.stepName = 'switch_back'; + export default InPersonSwitchBackStep; diff --git a/app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx b/app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx new file mode 100644 index 00000000000..05af9600651 --- /dev/null +++ b/app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx @@ -0,0 +1,53 @@ +import sinon from 'sinon'; +import { renderHook } from '@testing-library/react-hooks'; +import type { ComponentType } from 'react'; +import useStepLogger, { LOGGED_STEPS } from './use-step-logger'; +import AnalyticsContext from '../context/analytics'; + +describe('useStepLogger', () => { + let trackEvent: sinon.SinonStub; + let wrapper: ComponentType; + + beforeEach(() => { + trackEvent = sinon.stub(); + wrapper = ({ children }) => ( + {children} + ); + }); + + context('with step not included in allowlist', () => { + it('does not log visit', () => { + renderHook(() => useStepLogger('excluded'), { wrapper }); + + expect(trackEvent).not.to.have.been.called(); + }); + + it('does not log submission', () => { + const { result } = renderHook(() => useStepLogger('excluded'), { wrapper }); + const { onStepSubmit } = result.current; + + onStepSubmit(); + + expect(trackEvent).not.to.have.been.called(); + }); + }); + + context('with step included in allowlist', () => { + it('logs visit', () => { + const stepName = LOGGED_STEPS[0]; + renderHook(() => useStepLogger(stepName), { wrapper }); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} visited`); + }); + + it('logs submission', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useStepLogger(stepName), { wrapper }); + const { onStepSubmit } = result.current; + + onStepSubmit(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} submitted`); + }); + }); +}); diff --git a/app/javascript/packages/document-capture/hooks/use-step-logger.ts b/app/javascript/packages/document-capture/hooks/use-step-logger.ts new file mode 100644 index 00000000000..5257dc7f3fb --- /dev/null +++ b/app/javascript/packages/document-capture/hooks/use-step-logger.ts @@ -0,0 +1,34 @@ +import { useCallback, useContext, useEffect } from 'react'; +import AnalyticsContext from '../context/analytics'; +import InPersonLocationStep from '../components/in-person-location-step'; +import InPersonPrepareStep from '../components/in-person-prepare-step'; +import InPersonSwitchBackStep from '../components/in-person-switch-back-step'; + +export const LOGGED_STEPS: string[] = [ + InPersonLocationStep.stepName, + InPersonPrepareStep.stepName, + InPersonSwitchBackStep.stepName, +]; + +const isLoggedStep = (stepName?: string): boolean => !!stepName && LOGGED_STEPS.includes(stepName); + +function useStepLogger(currentStep?: string) { + const { trackEvent } = useContext(AnalyticsContext); + const onStepSubmit = useCallback( + (stepName?: string) => { + if (isLoggedStep(stepName)) { + trackEvent(`IdV: ${stepName} submitted`); + } + }, + [trackEvent], + ); + useEffect(() => { + if (isLoggedStep(currentStep)) { + trackEvent(`IdV: ${currentStep} visited`); + } + }, [currentStep]); + + return { onStepSubmit }; +} + +export default useStepLogger; diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 953a7ad58ce..fde03dceffe 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -500,6 +500,42 @@ def idv_verify_in_person_troubleshooting_option_clicked track_event('IdV: verify in person troubleshooting option clicked') end + # The user visited the in person proofing location step + # @param [String] flow_path Path used for document capture ("standard" or "hybrid") + def idv_in_person_location_visited(**extra) + track_event('IdV: in person proofing location visited', **extra) + end + + # The user submitted the in person proofing location step + # @param [String] flow_path Path used for document capture ("standard" or "hybrid") + def idv_in_person_location_submitted(**extra) + track_event('IdV: in person proofing location submitted', **extra) + end + + # The user visited the in person proofing prepare step + # @param [String] flow_path Path used for document capture ("standard" or "hybrid") + def idv_in_person_prepare_visited(**extra) + track_event('IdV: in person proofing prepare visited', **extra) + end + + # The user submitted the in person proofing prepare step + # @param [String] flow_path Path used for document capture ("standard" or "hybrid") + def idv_in_person_prepare_submitted(**extra) + track_event('IdV: in person proofing prepare submitted', **extra) + end + + # The user visited the in person proofing switch_back step + # @param [String] flow_path Path used for document capture ("standard" or "hybrid") + def idv_in_person_switch_back_visited(**extra) + track_event('IdV: in person proofing switch_back visited', **extra) + end + + # The user submitted the in person proofing switch_back step + # @param [String] flow_path Path used for document capture ("standard" or "hybrid") + def idv_in_person_switch_back_submitted(**extra) + track_event('IdV: in person proofing switch_back submitted', **extra) + end + # The user visited the "ready to verify" page for the in person proofing flow def idv_in_person_ready_to_verify_visit track_event('IdV: in person ready to verify visited') diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 755b332bf2e..59e27324d1c 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -2,6 +2,7 @@ feature 'Analytics Regression', js: true do include IdvStepHelper + include InPersonHelper let(:user) { user_with_2fa } let(:fake_analytics) { FakeAnalytics.new } @@ -79,6 +80,54 @@ FSMv2: common_events, } end + let(:in_person_path_events) do + { + 'IdV: doc auth welcome visited' => { flow_path: 'standard', step: 'welcome', step_count: 1 }, + 'IdV: doc auth welcome submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'welcome', step_count: 1 }, + 'IdV: doc auth agreement visited' => { flow_path: 'standard', step: 'agreement', step_count: 1 }, + 'IdV: doc auth agreement submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'agreement', step_count: 1 }, + 'IdV: doc auth upload visited' => { flow_path: 'standard', step: 'upload', step_count: 1 }, + 'IdV: doc auth upload submitted' => { success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'upload', step_count: 1 }, + 'IdV: doc auth document_capture visited' => { flow_path: 'standard', step: 'document_capture', step_count: 1 }, + 'Frontend: IdV: front image added' => { 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard' }, + 'Frontend: IdV: document capture async upload encryption' => { 'success' => true, 'flow_path' => 'standard' }, + 'Frontend: IdV: back image added' => { 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard' }, + 'Frontend: IdV: document capture async upload submitted' => { 'success' => true, 'trace_id' => nil, 'status_code' => 200, 'flow_path' => 'standard' }, + 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: nil, remaining_attempts: 3, user_id: nil, flow_path: 'standard' }, + 'IdV: doc auth image upload vendor submitted' => { success: true, flow_path: 'standard', attention_with_barcode: true, doc_auth_result: 'Attention' }, + 'IdV: doc auth verify_document_status submitted' => { success: true, flow_path: 'standard', step: 'verify_document_status', attention_with_barcode: true, doc_auth_result: 'Attention' }, + 'IdV: verify in person troubleshooting option clicked' => {}, + 'IdV: in person proofing location visited' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing location submitted' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing prepare visited' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing prepare submitted' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing state_id visited' => { step: 'state_id', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing state_id submitted' => { success: true, flow_path: 'standard', step: 'state_id', step_count: 1 }, + 'IdV: in person proofing address visited' => { step: 'address', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing address submitted' => { success: true, step: 'address', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing ssn visited' => { step: 'ssn', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing ssn submitted' => { success: true, step: 'ssn', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing verify visited' => { step: 'verify', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing verify submitted' => { success: true, step: 'verify', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 }, + 'IdV: in person proofing optional verify_wait submitted' => { success: true, step: 'verify_wait_step_show', address_edited: false, ssn_is_unique: true }, + 'IdV: phone of record visited' => {}, + 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202' }, + 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, context: { stages: [{ address: 'AddressMock' }] }, transaction_id: 'address-mock-transaction-id-123', timed_out: false }, new_phone_added: false }, + 'IdV: Phone OTP delivery Selection Visited' => {}, + 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms' }, + 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202' }, + 'IdV: phone confirmation otp visited' => {}, + 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil }, + 'IdV: review info visited' => {}, + 'IdV: review complete' => { success: true }, + 'IdV: final resolution' => { success: true }, + 'IdV: personal key visited' => {}, + 'Frontend: IdV: show personal key modal' => {}, + 'IdV: personal key submitted' => {}, + 'IdV: in person ready to verify visited' => {}, + } + end # rubocop:enable Layout/LineLength # Needed for enqueued_at in gpo_step @@ -88,6 +137,8 @@ before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(DocumentProofingJob).to receive(:build_analytics). + and_return(fake_analytics) end { @@ -149,4 +200,23 @@ end end end + + context 'in person path' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + complete_all_in_person_proofing_steps(user) + complete_phone_step(user) + complete_review_step(user) + acknowledge_and_confirm_personal_key + end + + it 'records all of the events', allow_browser_log: true do + in_person_path_events.each do |event, attributes| + expect(fake_analytics).to have_logged_event(event, attributes) + end + end + end end From afd4dc45aa057a8c08abcbd50e8cb378273285aa Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 6 Sep 2022 13:13:32 -0400 Subject: [PATCH 03/12] Remove parameters Appease linter --- app/services/analytics_events.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index fde03dceffe..b8e8bf063c8 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -501,37 +501,31 @@ def idv_verify_in_person_troubleshooting_option_clicked end # The user visited the in person proofing location step - # @param [String] flow_path Path used for document capture ("standard" or "hybrid") def idv_in_person_location_visited(**extra) track_event('IdV: in person proofing location visited', **extra) end # The user submitted the in person proofing location step - # @param [String] flow_path Path used for document capture ("standard" or "hybrid") def idv_in_person_location_submitted(**extra) track_event('IdV: in person proofing location submitted', **extra) end # The user visited the in person proofing prepare step - # @param [String] flow_path Path used for document capture ("standard" or "hybrid") def idv_in_person_prepare_visited(**extra) track_event('IdV: in person proofing prepare visited', **extra) end # The user submitted the in person proofing prepare step - # @param [String] flow_path Path used for document capture ("standard" or "hybrid") def idv_in_person_prepare_submitted(**extra) track_event('IdV: in person proofing prepare submitted', **extra) end # The user visited the in person proofing switch_back step - # @param [String] flow_path Path used for document capture ("standard" or "hybrid") def idv_in_person_switch_back_visited(**extra) track_event('IdV: in person proofing switch_back visited', **extra) end # The user submitted the in person proofing switch_back step - # @param [String] flow_path Path used for document capture ("standard" or "hybrid") def idv_in_person_switch_back_submitted(**extra) track_event('IdV: in person proofing switch_back submitted', **extra) end From d46eda7be5b4a345d5d56449eea02db7d2bc19fa Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 7 Sep 2022 14:56:50 -0400 Subject: [PATCH 04/12] Manage step visit, submit events as part of AnalyticsContext To centralize storage of step metadata Reverts to string step names (for now) to avoid dependency cycle between analytics context and steps --- .../components/document-capture.tsx | 26 +++---- .../components/in-person-location-step.tsx | 13 ++-- .../components/in-person-prepare-step.tsx | 2 - .../components/in-person-switch-back-step.tsx | 2 - .../document-capture/context/analytics.jsx | 28 -------- .../document-capture/context/analytics.tsx | 72 +++++++++++++++++++ .../document-capture/context/index.ts | 2 +- .../hooks/use-step-logger.spec.tsx | 53 -------------- .../document-capture/hooks/use-step-logger.ts | 34 --------- app/javascript/packs/document-capture.tsx | 4 +- spec/features/idv/analytics_spec.rb | 2 +- 11 files changed, 97 insertions(+), 141 deletions(-) delete mode 100644 app/javascript/packages/document-capture/context/analytics.jsx create mode 100644 app/javascript/packages/document-capture/context/analytics.tsx delete mode 100644 app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx delete mode 100644 app/javascript/packages/document-capture/hooks/use-step-logger.ts diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index a4bcf01b7c1..082cca85521 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -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'; @@ -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'; @@ -21,7 +22,6 @@ import { BackgroundEncryptedUploadError } from '../higher-order/with-background- import SuspenseErrorBoundary from './suspense-error-boundary'; import SubmissionInterstitial from './submission-interstitial'; import withProps from '../higher-order/with-props'; -import useStepLogger from '../hooks/use-step-logger'; /** * Returns a new object with specified keys removed. @@ -59,9 +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); - const { onStepSubmit } = useStepLogger(stepName); useDidUpdateEffect(onStepChange, [stepName]); + useEffect(() => { + if (stepName) { + trackVisitEvent(stepName); + } + }, [stepName]); /** * Clears error state and sets form values for submission. @@ -106,15 +111,15 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum ? [] : ([ { - name: InPersonLocationStep.stepName, + name: 'location', form: InPersonLocationStep, }, { - name: InPersonPrepareStep.stepName, + name: 'prepare', form: InPersonPrepareStep, }, flowPath === 'hybrid' && { - name: InPersonSwitchBackStep.stepName, + name: 'switch_back', form: InPersonSwitchBackStep, }, ].filter(Boolean) as FormStep[]); @@ -148,12 +153,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum ].filter(Boolean) as FormStep[]); const stepIndicatorPath = - stepName && - [ - InPersonLocationStep.stepName, - InPersonPrepareStep.stepName, - InPersonSwitchBackStep.stepName, - ].includes(stepName) + stepName && ['location', 'prepare', 'switch_back'].includes(stepName) ? VerifyFlowPath.IN_PERSON : VerifyFlowPath.DEFAULT; @@ -189,7 +189,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum initialActiveErrors={initialActiveErrors} onComplete={submitForm} onStepChange={setStepName} - onStepSubmit={onStepSubmit} + onStepSubmit={trackSubmitEvent} autoFocus={!!submissionError} /> diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.tsx index a25c0831ac9..29ad0581281 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.tsx @@ -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; @@ -79,6 +80,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); @@ -93,7 +95,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; } @@ -102,7 +107,7 @@ 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; @@ -192,6 +197,4 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { ); } -InPersonLocationStep.stepName = 'location'; - export default InPersonLocationStep; diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx index 5f8f8be60b7..bb62cba5354 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx @@ -133,6 +133,4 @@ function InPersonPrepareStep({ toPreviousStep, value }) { ); } -InPersonPrepareStep.stepName = 'prepare'; - export default InPersonPrepareStep; diff --git a/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx b/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx index 004a318ffba..bcf11f094c0 100644 --- a/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx @@ -23,6 +23,4 @@ function InPersonSwitchBackStep({ onChange }: FormStepComponentProps) { ); } -InPersonSwitchBackStep.stepName = 'switch_back'; - export default InPersonSwitchBackStep; diff --git a/app/javascript/packages/document-capture/context/analytics.jsx b/app/javascript/packages/document-capture/context/analytics.jsx deleted file mode 100644 index da28a1a3fa3..00000000000 --- a/app/javascript/packages/document-capture/context/analytics.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createContext } from 'react'; - -/** @typedef {import('@18f/identity-analytics').trackEvent} TrackEvent */ -/** @typedef {Record} Payload */ - -/** - * @typedef PageAction - * - * @property {string=} key Short, camel-cased, dot-namespaced key describing event. - * @property {string} label Long-form, human-readable label describing event action. - * @property {Payload=} payload Additional payload arguments to log with action. - */ - -/** - * @typedef AnalyticsContext - * - * @prop {TrackEvent} trackEvent Log an action with optional payload. - */ - -const AnalyticsContext = createContext( - /** @type {AnalyticsContext} */ ({ - trackEvent: () => Promise.resolve(), - }), -); - -AnalyticsContext.displayName = 'AnalyticsContext'; - -export default AnalyticsContext; diff --git a/app/javascript/packages/document-capture/context/analytics.tsx b/app/javascript/packages/document-capture/context/analytics.tsx new file mode 100644 index 00000000000..014af567aa6 --- /dev/null +++ b/app/javascript/packages/document-capture/context/analytics.tsx @@ -0,0 +1,72 @@ +import { createContext, useState } from 'react'; +import type { ReactChildren } from 'react'; +import type { trackEvent } from '@18f/identity-analytics'; + +type SetSubmitEventMetadata = (metadata: Record) => void; + +type TrackSubmitEvent = (stepName: string) => void; + +type TrackVisitEvent = (stepName: string) => void; + +interface AnalyticsContextValue { + /** + * Log an action with optional payload. + */ + trackEvent: typeof trackEvent; + + /** + * Callback to trigger logging when a step is submitted. + */ + trackSubmitEvent: TrackSubmitEvent; + + /** + * Callback to trigger logging when a step is visited. + */ + trackVisitEvent: TrackVisitEvent; + + /** + * Sets additional metadata to be included in the next tracked submit event. + */ + setSubmitEventMetadata: SetSubmitEventMetadata; +} + +type AnalyticsContextProviderProps = Pick & { + children: ReactChildren; +}; + +const DEFAULT_EVENT_METADATA: Record = {}; + +const LOGGED_STEPS: string[] = ['location', 'prepare', 'switch_back']; + +const AnalyticsContext = createContext({ + trackEvent: () => Promise.resolve(), + trackSubmitEvent() {}, + trackVisitEvent() {}, + setSubmitEventMetadata() {}, +}); + +AnalyticsContext.displayName = 'AnalyticsContext'; + +export function AnalyticsContextProvider({ children, trackEvent }: AnalyticsContextProviderProps) { + const [submitEventMetadata, setSubmitEventMetadataState] = useState(DEFAULT_EVENT_METADATA); + const setSubmitEventMetadata: SetSubmitEventMetadata = (metadata) => + setSubmitEventMetadataState((prevState) => ({ ...prevState, ...metadata })); + const trackSubmitEvent: TrackSubmitEvent = (stepName) => { + if (LOGGED_STEPS.includes(stepName)) { + trackEvent(`IdV: ${stepName} submitted`, submitEventMetadata); + } + + setSubmitEventMetadataState(DEFAULT_EVENT_METADATA); + }; + const trackVisitEvent: TrackVisitEvent = (stepName) => { + if (LOGGED_STEPS.includes(stepName)) { + trackEvent(`IdV: ${stepName} visited`); + } + }; + + const value = { trackEvent, trackVisitEvent, trackSubmitEvent, setSubmitEventMetadata }; + + return {children}; +} + +export default AnalyticsContext; diff --git a/app/javascript/packages/document-capture/context/index.ts b/app/javascript/packages/document-capture/context/index.ts index 66c62141ecf..c8a755f5fa3 100644 --- a/app/javascript/packages/document-capture/context/index.ts +++ b/app/javascript/packages/document-capture/context/index.ts @@ -10,7 +10,7 @@ export { default as ServiceProviderContext, Provider as ServiceProviderContextProvider, } from './service-provider'; -export { default as AnalyticsContext } from './analytics'; +export { default as AnalyticsContext, AnalyticsContextProvider } from './analytics'; export { default as FailedCaptureAttemptsContext, Provider as FailedCaptureAttemptsContextProvider, diff --git a/app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx b/app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx deleted file mode 100644 index 05af9600651..00000000000 --- a/app/javascript/packages/document-capture/hooks/use-step-logger.spec.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import sinon from 'sinon'; -import { renderHook } from '@testing-library/react-hooks'; -import type { ComponentType } from 'react'; -import useStepLogger, { LOGGED_STEPS } from './use-step-logger'; -import AnalyticsContext from '../context/analytics'; - -describe('useStepLogger', () => { - let trackEvent: sinon.SinonStub; - let wrapper: ComponentType; - - beforeEach(() => { - trackEvent = sinon.stub(); - wrapper = ({ children }) => ( - {children} - ); - }); - - context('with step not included in allowlist', () => { - it('does not log visit', () => { - renderHook(() => useStepLogger('excluded'), { wrapper }); - - expect(trackEvent).not.to.have.been.called(); - }); - - it('does not log submission', () => { - const { result } = renderHook(() => useStepLogger('excluded'), { wrapper }); - const { onStepSubmit } = result.current; - - onStepSubmit(); - - expect(trackEvent).not.to.have.been.called(); - }); - }); - - context('with step included in allowlist', () => { - it('logs visit', () => { - const stepName = LOGGED_STEPS[0]; - renderHook(() => useStepLogger(stepName), { wrapper }); - - expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} visited`); - }); - - it('logs submission', () => { - const stepName = LOGGED_STEPS[0]; - const { result } = renderHook(() => useStepLogger(stepName), { wrapper }); - const { onStepSubmit } = result.current; - - onStepSubmit(stepName); - - expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} submitted`); - }); - }); -}); diff --git a/app/javascript/packages/document-capture/hooks/use-step-logger.ts b/app/javascript/packages/document-capture/hooks/use-step-logger.ts deleted file mode 100644 index 5257dc7f3fb..00000000000 --- a/app/javascript/packages/document-capture/hooks/use-step-logger.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback, useContext, useEffect } from 'react'; -import AnalyticsContext from '../context/analytics'; -import InPersonLocationStep from '../components/in-person-location-step'; -import InPersonPrepareStep from '../components/in-person-prepare-step'; -import InPersonSwitchBackStep from '../components/in-person-switch-back-step'; - -export const LOGGED_STEPS: string[] = [ - InPersonLocationStep.stepName, - InPersonPrepareStep.stepName, - InPersonSwitchBackStep.stepName, -]; - -const isLoggedStep = (stepName?: string): boolean => !!stepName && LOGGED_STEPS.includes(stepName); - -function useStepLogger(currentStep?: string) { - const { trackEvent } = useContext(AnalyticsContext); - const onStepSubmit = useCallback( - (stepName?: string) => { - if (isLoggedStep(stepName)) { - trackEvent(`IdV: ${stepName} submitted`); - } - }, - [trackEvent], - ); - useEffect(() => { - if (isLoggedStep(currentStep)) { - trackEvent(`IdV: ${currentStep} visited`); - } - }, [currentStep]); - - return { onStepSubmit }; -} - -export default useStepLogger; diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 9572cd89663..37dcfa8496a 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -7,7 +7,7 @@ import { AcuantContextProvider, UploadContextProvider, ServiceProviderContextProvider, - AnalyticsContext, + AnalyticsContextProvider, FailedCaptureAttemptsContextProvider, MarketingSiteContextProvider, } from '@18f/identity-document-capture'; @@ -116,7 +116,7 @@ const trackEvent: typeof baseTrackEvent = (event, payload) => { [AppContext.Provider, { value: { appName } }], [MarketingSiteContextProvider, { helpCenterRedirectURL, securityAndPrivacyHowItWorksURL }], [DeviceContext.Provider, { value: device }], - [AnalyticsContext.Provider, { value: { trackEvent } }], + [AnalyticsContextProvider, { trackEvent }], [ AcuantContextProvider, { diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 59e27324d1c..d57f25ff86d 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -98,7 +98,7 @@ 'IdV: doc auth verify_document_status submitted' => { success: true, flow_path: 'standard', step: 'verify_document_status', attention_with_barcode: true, doc_auth_result: 'Attention' }, 'IdV: verify in person troubleshooting option clicked' => {}, 'IdV: in person proofing location visited' => { 'flow_path' => 'standard' }, - 'IdV: in person proofing location submitted' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing location submitted' => { 'flow_path' => 'standard', 'selected_location' => 'BALTIMORE' }, 'IdV: in person proofing prepare visited' => { 'flow_path' => 'standard' }, 'IdV: in person proofing prepare submitted' => { 'flow_path' => 'standard' }, 'IdV: in person proofing state_id visited' => { step: 'state_id', flow_path: 'standard', step_count: 1 }, From fddc8de7f966529facd3808a9f2b326a2fe97f74 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 7 Sep 2022 15:36:47 -0400 Subject: [PATCH 05/12] Fix children type for AnalyticsProvider props --- .../packages/document-capture/context/analytics.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/packages/document-capture/context/analytics.tsx b/app/javascript/packages/document-capture/context/analytics.tsx index 014af567aa6..ad997da8df5 100644 --- a/app/javascript/packages/document-capture/context/analytics.tsx +++ b/app/javascript/packages/document-capture/context/analytics.tsx @@ -1,5 +1,5 @@ import { createContext, useState } from 'react'; -import type { ReactChildren } from 'react'; +import type { ReactChild } from 'react'; import type { trackEvent } from '@18f/identity-analytics'; type SetSubmitEventMetadata = (metadata: Record) => void; @@ -31,7 +31,7 @@ interface AnalyticsContextValue { } type AnalyticsContextProviderProps = Pick & { - children: ReactChildren; + children: ReactChild; }; const DEFAULT_EVENT_METADATA: Record = {}; From 1a2a34ffa5b57eb8f5d9d1920fcd38476d5a06df Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 7 Sep 2022 15:56:03 -0400 Subject: [PATCH 06/12] Add specs for InPersonLocationStep --- .../in-person-location-step.spec.tsx | 43 +++++++++++++++++++ .../components/in-person-location-step.tsx | 13 ++---- .../document-capture/context/analytics.tsx | 22 ++++++++-- 3 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx new file mode 100644 index 00000000000..25bed2d9031 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx @@ -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( + + + + , + ); + + 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"}'); + }); +}); diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.tsx index 29ad0581281..43db03c1738 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.tsx @@ -30,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[]; @@ -115,7 +108,7 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { headers['X-CSRF-Token'] = csrf; } setInProgress(true); - await fetch(locationUrl, { + await fetch(LOCATIONS_URL, { method: 'PUT', body: JSON.stringify(selected), headers, diff --git a/app/javascript/packages/document-capture/context/analytics.tsx b/app/javascript/packages/document-capture/context/analytics.tsx index ad997da8df5..f3970c6897d 100644 --- a/app/javascript/packages/document-capture/context/analytics.tsx +++ b/app/javascript/packages/document-capture/context/analytics.tsx @@ -1,8 +1,10 @@ import { createContext, useState } from 'react'; -import type { ReactChild } from 'react'; +import type { ReactNode } from 'react'; import type { trackEvent } from '@18f/identity-analytics'; -type SetSubmitEventMetadata = (metadata: Record) => void; +type EventMetadata = Record; + +type SetSubmitEventMetadata = (metadata: EventMetadata) => void; type TrackSubmitEvent = (stepName: string) => void; @@ -24,6 +26,11 @@ interface AnalyticsContextValue { */ trackVisitEvent: TrackVisitEvent; + /** + * Additional metadata to be included in the next tracked submit event. + */ + submitEventMetadata: EventMetadata; + /** * Sets additional metadata to be included in the next tracked submit event. */ @@ -31,7 +38,7 @@ interface AnalyticsContextValue { } type AnalyticsContextProviderProps = Pick & { - children: ReactChild; + children: ReactNode; }; const DEFAULT_EVENT_METADATA: Record = {}; @@ -42,6 +49,7 @@ const AnalyticsContext = createContext({ trackEvent: () => Promise.resolve(), trackSubmitEvent() {}, trackVisitEvent() {}, + submitEventMetadata: DEFAULT_EVENT_METADATA, setSubmitEventMetadata() {}, }); @@ -64,7 +72,13 @@ export function AnalyticsContextProvider({ children, trackEvent }: AnalyticsCont } }; - const value = { trackEvent, trackVisitEvent, trackSubmitEvent, setSubmitEventMetadata }; + const value = { + trackEvent, + trackVisitEvent, + trackSubmitEvent, + submitEventMetadata, + setSubmitEventMetadata, + }; return {children}; } From 8520fb29ac623bc91ce4a8c7a7f55432e3ca7a2d Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 8 Sep 2022 09:00:52 -0400 Subject: [PATCH 07/12] Add specs for AnalyticsContextProvider --- .../context/analytics.spec.tsx | 69 +++++++++++++++++++ .../document-capture/context/analytics.tsx | 2 +- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 app/javascript/packages/document-capture/context/analytics.spec.tsx diff --git a/app/javascript/packages/document-capture/context/analytics.spec.tsx b/app/javascript/packages/document-capture/context/analytics.spec.tsx new file mode 100644 index 00000000000..d3f66767c9c --- /dev/null +++ b/app/javascript/packages/document-capture/context/analytics.spec.tsx @@ -0,0 +1,69 @@ +import sinon from 'sinon'; +import { useContext } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import type { ComponentType } from 'react'; +import AnalyticsContext, { AnalyticsContextProvider, LOGGED_STEPS } from './analytics'; + +describe('AnalyticsContextProvider', () => { + let trackEvent: sinon.SinonStub; + let wrapper: ComponentType; + beforeEach(() => { + trackEvent = sinon.stub(); + wrapper = ({ children }) => ( + {children} + ); + }); + + it('provides default context values', () => { + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + expect(result.current).to.have.all.keys([ + 'trackEvent', + 'trackSubmitEvent', + 'trackVisitEvent', + 'submitEventMetadata', + 'setSubmitEventMetadata', + ]); + }); + + it('calls trackEvent with visit event', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.trackVisitEvent(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} visited`); + }); + + it('calls trackEvent with submit event', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.trackSubmitEvent(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} submitted`, {}); + }); + + it('includes metadata in the next submit event', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.setSubmitEventMetadata({ ok: true }); + result.current.trackSubmitEvent(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} submitted`, { ok: true }); + }); + + it('does not include metadata in subsequent submit events', () => { + const firstStepName = LOGGED_STEPS[0]; + const secondStepName = LOGGED_STEPS[1]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.setSubmitEventMetadata({ ok: true }); + result.current.trackSubmitEvent(firstStepName); + result.current.trackSubmitEvent(secondStepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${firstStepName} submitted`, { ok: true }); + expect(trackEvent).to.have.been.calledWith(`IdV: ${secondStepName} submitted`, {}); + }); +}); diff --git a/app/javascript/packages/document-capture/context/analytics.tsx b/app/javascript/packages/document-capture/context/analytics.tsx index f3970c6897d..cd1e513a466 100644 --- a/app/javascript/packages/document-capture/context/analytics.tsx +++ b/app/javascript/packages/document-capture/context/analytics.tsx @@ -43,7 +43,7 @@ type AnalyticsContextProviderProps = Pick & const DEFAULT_EVENT_METADATA: Record = {}; -const LOGGED_STEPS: string[] = ['location', 'prepare', 'switch_back']; +export const LOGGED_STEPS: string[] = ['location', 'prepare', 'switch_back']; const AnalyticsContext = createContext({ trackEvent: () => Promise.resolve(), From 17c1f1ee2755adc44c6f657a9d480165706858e5 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 8 Sep 2022 09:03:20 -0400 Subject: [PATCH 08/12] Resolve TypeScript errors for context value shape Use provider wrapper to handle creation of full context value --- .../document-capture-troubleshooting-options.spec.tsx | 6 +++--- .../components/in-person-prepare-step.spec.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx index c3c2e55a847..b08b07acfaa 100644 --- a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx @@ -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'; @@ -165,9 +165,9 @@ describe('DocumentCaptureTroubleshootingOptions', () => { it('logs an event when clicking the troubleshooting option', async () => { const trackEvent = sinon.stub(); const { getByRole } = render( - + - , + , { wrapper }, ); 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 490cfb73bd1..3d84db32143 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 @@ -5,7 +5,7 @@ import type { ComponentType } from 'react'; import { FlowContext } from '@18f/identity-verify-flow'; import type { FlowContextValue } from '@18f/identity-verify-flow'; import { Provider as MarketingSiteContextProvider } from '../context/marketing-site'; -import AnalyticsContext from '../context/analytics'; +import { AnalyticsContextProvider } from '../context/analytics'; import InPersonPrepareStep from './in-person-prepare-step'; describe('InPersonPrepareStep', () => { @@ -33,9 +33,9 @@ describe('InPersonPrepareStep', () => { it('logs prepare step submission when clicking continue', async () => { const trackEvent = sinon.stub(); const { getByRole } = render( - + - , + , { wrapper }, ); From 72de85158ff7b9487b1e7a35bfed78befd771def Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 8 Sep 2022 09:12:42 -0400 Subject: [PATCH 09/12] Absorb thrown network error in trackEvent --- app/javascript/packages/analytics/index.spec.ts | 11 +++++++++++ app/javascript/packages/analytics/index.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/javascript/packages/analytics/index.spec.ts b/app/javascript/packages/analytics/index.spec.ts index d4f8909d0c0..8e541f492d3 100644 --- a/app/javascript/packages/analytics/index.spec.ts +++ b/app/javascript/packages/analytics/index.spec.ts @@ -1,3 +1,4 @@ +import type { SinonStub } from 'sinon'; import { trackEvent, trackError } from '@18f/identity-analytics'; import { usePropertyValue, useSandbox } from '@18f/identity-test-helpers'; @@ -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'); + }); + }); }); }); diff --git a/app/javascript/packages/analytics/index.ts b/app/javascript/packages/analytics/index.ts index 6df46168ee1..a66e64fae61 100644 --- a/app/javascript/packages/analytics/index.ts +++ b/app/javascript/packages/analytics/index.ts @@ -17,12 +17,21 @@ interface NewRelicGlobals { */ export async function trackEvent(event: string, payload: object = {}): Promise { 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. } } From a326e76d999d3c6c4108f84dbe15a6b1da72297f Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 8 Sep 2022 09:16:44 -0400 Subject: [PATCH 10/12] Add reference to spec --- app/javascript/packages/analytics/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/packages/analytics/index.ts b/app/javascript/packages/analytics/index.ts index a66e64fae61..a13eab8c614 100644 --- a/app/javascript/packages/analytics/index.ts +++ b/app/javascript/packages/analytics/index.ts @@ -32,6 +32,8 @@ export async function trackEvent(event: string, payload: object = {}): Promise Date: Fri, 9 Sep 2022 09:08:16 -0400 Subject: [PATCH 11/12] Use SpinnerButton for prepare step submission **Why**: - Prevent multiple event logging if user were to click multiple times in quick succession - Present feedback to user for pending network request before navigation --- .../in-person-prepare-step.spec.tsx | 33 ++++++++++++++++++- .../components/in-person-prepare-step.tsx | 27 +++++++-------- 2 files changed, 44 insertions(+), 16 deletions(-) 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 3d84db32143..18203bb11a6 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 @@ -1,9 +1,10 @@ import sinon from 'sinon'; -import { render, waitFor } from '@testing-library/react'; +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'; @@ -44,6 +45,36 @@ describe('InPersonPrepareStep', () => { 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( + + + , + { 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', () => { diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx index bb62cba5354..41b271b2614 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx @@ -1,7 +1,7 @@ +import { useContext, useState } from 'react'; import type { MouseEventHandler } from 'react'; import { Alert, - Button, Link, IconList, IconListItem, @@ -10,11 +10,11 @@ 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'; @@ -23,6 +23,7 @@ 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); @@ -30,18 +31,14 @@ function InPersonPrepareStep({ toPreviousStep, value }) { const { selectedLocationName } = value; const onContinue: MouseEventHandler = async (event) => { - removeUnloadProtection(); + event.preventDefault(); - let destination; - if (event.target instanceof HTMLAnchorElement) { - event.preventDefault(); - destination = event.target.href; - } - - await trackEvent('IdV: prepare submitted'); - - if (destination) { - window.location = destination; + if (!isSubmitting) { + setIsSubmitting(true); + removeUnloadProtection(); + const destination = (event.target as HTMLAnchorElement).href; + await trackEvent('IdV: prepare submitted'); + window.location.href = destination; } }; @@ -109,9 +106,9 @@ function InPersonPrepareStep({ toPreviousStep, value }) { {flowPath === 'hybrid' && } {inPersonURL && flowPath === 'standard' && (
- +
)}

From 99def7d08048cca97232cc883117f0d0dab1ad3d Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 9 Sep 2022 09:10:45 -0400 Subject: [PATCH 12/12] Avoid unnecessary variable assignment --- .../document-capture/components/in-person-prepare-step.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx index 41b271b2614..b9e735862b2 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx @@ -36,9 +36,8 @@ function InPersonPrepareStep({ toPreviousStep, value }) { if (!isSubmitting) { setIsSubmitting(true); removeUnloadProtection(); - const destination = (event.target as HTMLAnchorElement).href; await trackEvent('IdV: prepare submitted'); - window.location.href = destination; + window.location.href = (event.target as HTMLAnchorElement).href; } };