From 97e4f0ced1d80aa0837204ad522a310a2ddb9cd4 Mon Sep 17 00:00:00 2001 From: Connor Lanigan Date: Mon, 17 Jul 2023 15:23:47 +0200 Subject: [PATCH] feat: Emit error events for Alerts (#1328) --- .../with-error-alert-in-wizard.page.tsx | 55 ++++++ src/alert/__tests__/alert-analytics.test.tsx | 183 ++++++++++++++++++ src/alert/index.tsx | 43 +++- 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 pages/funnel-analytics/with-error-alert-in-wizard.page.tsx create mode 100644 src/alert/__tests__/alert-analytics.test.tsx diff --git a/pages/funnel-analytics/with-error-alert-in-wizard.page.tsx b/pages/funnel-analytics/with-error-alert-in-wizard.page.tsx new file mode 100644 index 0000000000..07e49c5fb3 --- /dev/null +++ b/pages/funnel-analytics/with-error-alert-in-wizard.page.tsx @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import Wizard, { WizardProps } from '~components/wizard'; + +import { i18nStrings } from '../wizard/common'; +import Alert from '~components/alert'; +import Container from '~components/container'; +import Header from '~components/header'; + +export default function WizardPage() { + const [errorMode, setErrorMode] = useState(0); + + const steps: WizardProps.Step[] = [ + { + title: 'Step 1', + content: ( +
+
Content 1
+
{errorMode === 1 && This is an error on the step level}
+
+ {errorMode === 2 && ( + A container around the alert}> + This is an error on the substep level + + )} +
+
+ {errorMode === 3 && ( + <> + This is an error on the step level + A container around the alert}> + This is an error on the substep level + + + )} +
+
+ ), + }, + { + title: 'Step 2', + content:
Content 2
, + }, + ]; + + return ( + setErrorMode((errorMode + 1) % 4)} + /> + ); +} diff --git a/src/alert/__tests__/alert-analytics.test.tsx b/src/alert/__tests__/alert-analytics.test.tsx new file mode 100644 index 0000000000..47d70f657b --- /dev/null +++ b/src/alert/__tests__/alert-analytics.test.tsx @@ -0,0 +1,183 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { FunnelMetrics } from '../../../lib/components/internal/analytics'; +import { useFunnel } from '../../../lib/components/internal/analytics/hooks/use-funnel'; +import Alert from '../../../lib/components/alert'; + +import { + AnalyticsFunnel, + AnalyticsFunnelStep, + AnalyticsFunnelSubStep, +} from '../../../lib/components/internal/analytics/components/analytics-funnel'; + +import { mockFunnelMetrics } from '../../internal/analytics/__tests__/mocks'; + +describe('Alert Analytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFunnelMetrics(); + }); + + test('sends funnelSubStepError metric when the alert is placed inside a substep', () => { + render( + + + + This is the error text + + + + ); + + expect(FunnelMetrics.funnelError).not.toHaveBeenCalled(); + + expect(FunnelMetrics.funnelSubStepError).toHaveBeenCalledTimes(1); + expect(FunnelMetrics.funnelSubStepError).toHaveBeenCalledWith( + expect.objectContaining({ + funnelInteractionId: 'mocked-funnel-id', + subStepSelector: expect.any(String), + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + subStepNameSelector: expect.any(String), + }) + ); + }); + + test('sends funnelError metric when the alert is placed inside a step', () => { + render( + + + This is the error text + + + ); + + expect(FunnelMetrics.funnelSubStepError).not.toHaveBeenCalled(); + + expect(FunnelMetrics.funnelError).toHaveBeenCalledTimes(1); + expect(FunnelMetrics.funnelError).toHaveBeenCalledWith( + expect.objectContaining({ + funnelInteractionId: 'mocked-funnel-id', + }) + ); + }); + + test('does not send any error metric when the alert is invisible', () => { + render( + + + + + This is the error text + + + + + ); + + expect(FunnelMetrics.funnelSubStepError).not.toHaveBeenCalled(); + expect(FunnelMetrics.funnelError).not.toHaveBeenCalled(); + }); + + test('does not send any error metrics for non-error alerts', () => { + render( + + + + Default + Info + Success + Warning + + + + ); + + expect(FunnelMetrics.funnelSubStepError).not.toHaveBeenCalled(); + expect(FunnelMetrics.funnelError).not.toHaveBeenCalled(); + }); + + test('sends a funnelSubStepError metric when there is an error and the user attempts to submit the form', () => { + let funnelNextOrSubmitAttempt: undefined | (() => void) = undefined; + + const ChildComponent = () => { + funnelNextOrSubmitAttempt = useFunnel().funnelNextOrSubmitAttempt; + return <>; + }; + + const jsx = ( + + + + This is the error text + + + + + + ); + + const { rerender } = render(jsx); + expect(FunnelMetrics.funnelSubStepError).toHaveBeenCalledTimes(1); + + act(() => funnelNextOrSubmitAttempt!()); + rerender(jsx); + + expect(FunnelMetrics.funnelSubStepError).toHaveBeenCalledTimes(2); + }); + + test('does not send any error metrics when outside of a funnel context', () => { + render(This is the error text); + expect(FunnelMetrics.funnelSubStepError).not.toHaveBeenCalled(); + expect(FunnelMetrics.funnelError).not.toHaveBeenCalled(); + }); + + test('does not send multiple funnelSubStepError metrics on rerender', () => { + const { rerender } = render( + + + + This is the error text + + + + ); + + rerender( + + + + This is the error text + + + + ); + + expect(FunnelMetrics.funnelSubStepError).toHaveBeenCalledTimes(1); + expect(FunnelMetrics.funnelError).not.toHaveBeenCalled(); + }); + + test('does not send multiple funnelError metrics on rerender', () => { + const { rerender } = render( + + + This is the error text + + + ); + + rerender( + + + This is the error text + + + ); + + expect(FunnelMetrics.funnelError).toHaveBeenCalledTimes(1); + expect(FunnelMetrics.funnelSubStepError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/alert/index.tsx b/src/alert/index.tsx index 184f704593..877c0d4c17 100644 --- a/src/alert/index.tsx +++ b/src/alert/index.tsx @@ -1,15 +1,56 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useEffect } from 'react'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { AlertProps } from './interfaces'; import InternalAlert from './internal'; import useBaseComponent from '../internal/hooks/use-base-component'; +import { FunnelMetrics } from '../internal/analytics'; +import { useFunnel, useFunnelStep, useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; +import { getNameFromSelector, getSubStepAllSelector } from '../internal/analytics/selectors'; export { AlertProps }; export default function Alert({ type = 'info', visible = true, ...props }: AlertProps) { const baseComponentProps = useBaseComponent('Alert'); + + const { funnelInteractionId, submissionAttempt, funnelState, errorCount } = useFunnel(); + const { stepNumber, stepNameSelector } = useFunnelStep(); + const { subStepSelector, subStepNameSelector } = useFunnelSubStep(); + + useEffect(() => { + if (funnelInteractionId && visible && type === 'error' && funnelState.current !== 'complete') { + const stepName = getNameFromSelector(stepNameSelector); + const subStepName = getNameFromSelector(subStepNameSelector); + + errorCount.current++; + + if (subStepSelector) { + FunnelMetrics.funnelSubStepError({ + funnelInteractionId, + subStepSelector, + subStepName, + subStepNameSelector, + stepNumber, + stepName, + stepNameSelector, + subStepAllSelector: getSubStepAllSelector(), + }); + } else { + FunnelMetrics.funnelError({ + funnelInteractionId, + }); + } + + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + errorCount.current--; + }; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [funnelInteractionId, visible, submissionAttempt, errorCount]); + return ; }