From 6c2b098c83182ef163eb2933982a72afcf5ae59e Mon Sep 17 00:00:00 2001 From: Connor Lanigan Date: Fri, 15 Sep 2023 12:15:11 +0000 Subject: [PATCH] chore: Include `currentDocument` in funnel events --- src/alert/index.tsx | 4 +- src/button/internal.tsx | 1 + src/form-field/interfaces.ts | 2 +- src/form-field/internal.tsx | 1 + src/form/index.tsx | 15 ++++-- src/form/internal.tsx | 9 ++-- .../analytics/components/analytics-funnel.tsx | 48 +++++++++++++------ src/internal/analytics/hooks/use-funnel.ts | 2 + src/internal/analytics/interfaces.ts | 2 + .../hooks/use-base-component/index.ts | 6 +-- src/link/internal.tsx | 5 +- src/wizard/analytics.ts | 9 +++- src/wizard/index.tsx | 3 +- src/wizard/internal.tsx | 5 +- src/wizard/wizard-form.tsx | 1 + 15 files changed, 81 insertions(+), 32 deletions(-) diff --git a/src/alert/index.tsx b/src/alert/index.tsx index 2a47837ecd..67b168002b 100644 --- a/src/alert/index.tsx +++ b/src/alert/index.tsx @@ -13,7 +13,7 @@ export { AlertProps }; const Alert = React.forwardRef( ({ type = 'info', visible = true, ...props }: AlertProps, ref: React.Ref) => { - const baseComponentProps = useBaseComponent('Alert'); + const baseComponentProps = useBaseComponent('Alert'); const { funnelInteractionId, submissionAttempt, funnelState, errorCount } = useFunnel(); const { stepNumber, stepNameSelector } = useFunnelStep(); @@ -40,10 +40,12 @@ const Alert = React.forwardRef( stepName, stepNameSelector, subStepAllSelector: getSubStepAllSelector(), + currentDocument: baseComponentProps.__internalRootRef.current?.ownerDocument, }); } else { FunnelMetrics.funnelError({ funnelInteractionId, + currentDocument: baseComponentProps.__internalRootRef.current?.ownerDocument, }); } } diff --git a/src/button/internal.tsx b/src/button/internal.tsx index ad027a9dbf..56517a57f5 100644 --- a/src/button/internal.tsx +++ b/src/button/internal.tsx @@ -102,6 +102,7 @@ export const InternalButton = React.forwardRef( subStepNameSelector, elementSelector: getFunnelValueSelector(uniqueId), subStepAllSelector: getSubStepAllSelector(), + currentDocument: buttonRef.current?.ownerDocument, }); } } diff --git a/src/form-field/interfaces.ts b/src/form-field/interfaces.ts index 348ef05397..709014c652 100644 --- a/src/form-field/interfaces.ts +++ b/src/form-field/interfaces.ts @@ -82,7 +82,7 @@ export namespace FormFieldProps { } } -export interface InternalFormFieldProps extends FormFieldProps, InternalBaseComponentProps { +export interface InternalFormFieldProps extends FormFieldProps, InternalBaseComponentProps { /** * Visually hide the label. */ diff --git a/src/form-field/internal.tsx b/src/form-field/internal.tsx index 96d95fa7f9..1f08fe631d 100644 --- a/src/form-field/internal.tsx +++ b/src/form-field/internal.tsx @@ -141,6 +141,7 @@ export default function InternalFormField({ fieldErrorSelector: getFieldSlotSeletor(slotIds.error), fieldLabelSelector: getFieldSlotSeletor(slotIds.label), subStepAllSelector: getSubStepAllSelector(), + currentDocument: __internalRootRef?.current?.ownerDocument, }); } diff --git a/src/form/index.tsx b/src/form/index.tsx index a9d1ad495a..4a8906e204 100644 --- a/src/form/index.tsx +++ b/src/form/index.tsx @@ -32,11 +32,20 @@ const FormWithAnalytics = ({ variant = 'full-page', actions, ...props }: FormPro }; export default function Form({ variant = 'full-page', ...props }: FormProps) { - const baseComponentProps = useBaseComponent('Form'); + const baseComponentProps = useBaseComponent('Form'); return ( - - + + diff --git a/src/form/internal.tsx b/src/form/internal.tsx index 196b652948..5d1d6e91f0 100644 --- a/src/form/internal.tsx +++ b/src/form/internal.tsx @@ -15,7 +15,7 @@ import { useInternalI18n } from '../i18n/context'; import { useFunnel } from '../internal/analytics/hooks/use-funnel'; import { FunnelMetrics } from '../internal/analytics'; -type InternalFormProps = FormProps & InternalBaseComponentProps; +export type InternalFormProps = FormProps & InternalBaseComponentProps; export default function InternalForm({ children, @@ -37,13 +37,16 @@ export default function InternalForm({ useEffect(() => { if (funnelInteractionId && errorText) { errorCount.current++; - FunnelMetrics.funnelError({ funnelInteractionId }); + FunnelMetrics.funnelError({ + funnelInteractionId, + currentDocument: __internalRootRef?.current?.ownerDocument, + }); return () => { // eslint-disable-next-line react-hooks/exhaustive-deps errorCount.current--; }; } - }, [funnelInteractionId, errorText, submissionAttempt, errorCount]); + }, [funnelInteractionId, errorText, submissionAttempt, errorCount, __internalRootRef]); return (
diff --git a/src/internal/analytics/components/analytics-funnel.tsx b/src/internal/analytics/components/analytics-funnel.tsx index f612008782..5b13cf1ba0 100644 --- a/src/internal/analytics/components/analytics-funnel.tsx +++ b/src/internal/analytics/components/analytics-funnel.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { RefObject, useContext, useEffect, useRef, useState } from 'react'; import { FunnelStepContext, @@ -33,10 +33,15 @@ import { nodeBelongs } from '../../utils/node-belongs'; export const FUNNEL_VERSION = '1.2'; -type AnalyticsFunnelProps = { children?: React.ReactNode; stepConfiguration?: StepConfiguration[] } & Pick< - FunnelProps, - 'funnelType' | 'optionalStepNumbers' | 'totalFunnelSteps' ->; +type AnalyticsFunnelProps = { + children?: React.ReactNode; + stepConfiguration?: StepConfiguration[]; + /** + * This ref is used to determine the current `document` of the events. It is not important + * on which exact element the ref is placed, as long as it's in the same document as the funnel. + */ + elementRef: RefObject; +} & Pick; export const AnalyticsFunnel = (props: AnalyticsFunnelProps) => { const { isInFunnel } = useFunnel(); @@ -54,17 +59,17 @@ export const AnalyticsFunnel = (props: AnalyticsFunnelProps) => { }; export const CREATION_EDIT_FLOW_DONE_EVENT_NAME = 'awsui-creation-edit-flow-done'; -const onFunnelCancelled = ({ funnelInteractionId }: { funnelInteractionId: string }) => { - FunnelMetrics.funnelCancelled({ funnelInteractionId }); +const onFunnelCancelled = (props: Parameters[0]) => { + FunnelMetrics.funnelCancelled(props); document.dispatchEvent(new Event(CREATION_EDIT_FLOW_DONE_EVENT_NAME)); }; -const onFunnelComplete = ({ funnelInteractionId }: { funnelInteractionId: string }) => { - FunnelMetrics.funnelComplete({ funnelInteractionId }); +const onFunnelComplete = (props: Parameters[0]) => { + FunnelMetrics.funnelComplete(props); document.dispatchEvent(new Event(CREATION_EDIT_FLOW_DONE_EVENT_NAME)); }; -const InnerAnalyticsFunnel = ({ children, stepConfiguration, ...props }: AnalyticsFunnelProps) => { +const InnerAnalyticsFunnel = ({ children, stepConfiguration, elementRef, ...props }: AnalyticsFunnelProps) => { const [funnelInteractionId, setFunnelInteractionId] = useState(''); const [submissionAttempt, setSubmissionAttempt] = useState(0); const isVisualRefresh = useVisualRefresh(); @@ -85,6 +90,8 @@ const InnerAnalyticsFunnel = ({ children, stepConfiguration, ...props }: Analyti // The eslint-disable is required as we deliberately want this effect to run only once on mount and unmount, // hence we do not provide any dependencies. useEffect(() => { + const currentDocument = elementRef.current?.ownerDocument; + /* We run this effect with a delay, in order to detect whether this funnel contains a Wizard. If it does contain a Wizard, that Wizard should take precedence for handling the funnel, and @@ -111,6 +118,7 @@ const InnerAnalyticsFunnel = ({ children, stepConfiguration, ...props }: Analyti theme: isVisualRefresh ? 'vr' : 'classic', funnelVersion: FUNNEL_VERSION, stepConfiguration: stepConfiguration ?? singleStepFlowStepConfiguration, + currentDocument, }); setFunnelInteractionId(funnelInteractionId); @@ -128,14 +136,14 @@ const InnerAnalyticsFunnel = ({ children, stepConfiguration, ...props }: Analyti if (funnelState.current === 'validating') { // Finish the validation phase early. - onFunnelComplete({ funnelInteractionId }); + onFunnelComplete({ funnelInteractionId, currentDocument }); funnelState.current = 'complete'; } if (funnelState.current === 'complete') { - FunnelMetrics.funnelSuccessful({ funnelInteractionId }); + FunnelMetrics.funnelSuccessful({ funnelInteractionId, currentDocument }); } else { - onFunnelCancelled({ funnelInteractionId }); + onFunnelCancelled({ funnelInteractionId, currentDocument }); funnelState.current = 'cancelled'; } }; @@ -171,7 +179,7 @@ const InnerAnalyticsFunnel = ({ children, stepConfiguration, ...props }: Analyti /* If no validation errors are rendered, we treat the funnel as complete. */ - onFunnelComplete({ funnelInteractionId }); + onFunnelComplete({ funnelInteractionId, currentDocument: elementRef.current?.ownerDocument }); funnelState.current = 'complete'; } else { funnelState.current = 'default'; @@ -208,6 +216,11 @@ const InnerAnalyticsFunnel = ({ children, stepConfiguration, ...props }: Analyti type AnalyticsFunnelStepProps = { children?: React.ReactNode | ((props: FunnelStepContextValue) => React.ReactNode); + /** + * This ref is used to determine the current `document` of the events. It is not important + * on which exact element the ref is placed, as long as it's in the same document as the funnel. + */ + elementRef: RefObject; } & Pick; export const AnalyticsFunnelStep = (props: AnalyticsFunnelStepProps) => { @@ -263,7 +276,7 @@ function useStepChangeListener(handler: (stepConfiguration: SubStepConfiguration return stepChangeCallback; } -const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: AnalyticsFunnelStepProps) => { +const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector, elementRef }: AnalyticsFunnelStepProps) => { const { funnelInteractionId, funnelState, funnelType } = useFunnel(); const parentStep = useFunnelStep(); const parentStepExists = parentStep.isInStep; @@ -287,6 +300,7 @@ const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: An subStepAllSelector: getSubStepAllSelector(), totalSubSteps: subStepCount.current, subStepConfiguration, + currentDocument: elementRef.current?.ownerDocument, }); }); @@ -307,6 +321,7 @@ const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: An */ return; } + const currentDocument = elementRef.current?.ownerDocument; const stepName = getNameFromSelector(stepNameSelector); @@ -319,6 +334,7 @@ const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: An subStepAllSelector: getSubStepAllSelector(), totalSubSteps: subStepCount.current, subStepConfiguration: getSubStepConfiguration(), + currentDocument, }); } @@ -333,6 +349,7 @@ const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: An subStepAllSelector: getSubStepAllSelector(), // eslint-disable-next-line react-hooks/exhaustive-deps totalSubSteps: subStepCount.current, + currentDocument, }); } }; @@ -344,6 +361,7 @@ const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: An parentStepExists, funnelType, parentStepFunnelInteractionId, + elementRef, ]); const contextValue: FunnelStepContextValue = { diff --git a/src/internal/analytics/hooks/use-funnel.ts b/src/internal/analytics/hooks/use-funnel.ts index 3972437f47..e852c0df6a 100644 --- a/src/internal/analytics/hooks/use-funnel.ts +++ b/src/internal/analytics/hooks/use-funnel.ts @@ -74,6 +74,7 @@ export const useFunnelSubStep = () => { stepName, stepNameSelector, subStepAllSelector: getSubStepAllSelector(), + currentDocument: subStepRef.current?.ownerDocument, }); /* @@ -103,6 +104,7 @@ export const useFunnelSubStep = () => { stepName, stepNameSelector, subStepAllSelector: getSubStepAllSelector(), + currentDocument: subStepRef.current?.ownerDocument, }); } }; diff --git a/src/internal/analytics/interfaces.ts b/src/internal/analytics/interfaces.ts index 0b4f560797..633f8ae5a5 100644 --- a/src/internal/analytics/interfaces.ts +++ b/src/internal/analytics/interfaces.ts @@ -6,6 +6,7 @@ export type FunnelType = 'single-page' | 'multi-page'; // Common properties for all funnels export interface BaseFunnelProps { funnelInteractionId: string; + currentDocument: Document | undefined; } export interface FunnelProps extends BaseFunnelProps { @@ -23,6 +24,7 @@ export interface FunnelStartProps { funnelVersion: string; componentVersion: string; theme: string; + currentDocument: Document | undefined; } // A function type for a generic funnel method diff --git a/src/internal/hooks/use-base-component/index.ts b/src/internal/hooks/use-base-component/index.ts index 51727ab27c..2f64a10be6 100644 --- a/src/internal/hooks/use-base-component/index.ts +++ b/src/internal/hooks/use-base-component/index.ts @@ -1,13 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { MutableRefObject } from 'react'; +import { RefObject } from 'react'; import { useComponentMetadata } from '@cloudscape-design/component-toolkit/internal'; import { useTelemetry } from '../use-telemetry'; import { PACKAGE_VERSION } from '../../environment'; import useFocusVisible from '../focus-visible'; -export interface InternalBaseComponentProps { - __internalRootRef?: MutableRefObject | null; +export interface InternalBaseComponentProps { + __internalRootRef?: RefObject | null; } /** diff --git a/src/link/internal.tsx b/src/link/internal.tsx index 8403e269ae..11adde1b25 100644 --- a/src/link/internal.tsx +++ b/src/link/internal.tsx @@ -68,6 +68,8 @@ const InternalLink = React.forwardRef( const infoLinkLabelFromContext = useContext(InfoLinkLabelContext); + const linkRef = useRef(null); + const { funnelInteractionId } = useFunnel(); const { stepNumber, stepNameSelector } = useFunnelStep(); const { subStepSelector, subStepNameSelector } = useFunnelSubStep(); @@ -87,6 +89,7 @@ const InternalLink = React.forwardRef( subStepNameSelector, elementSelector: getFunnelValueSelector(uniqueId), subStepAllSelector: getSubStepAllSelector(), + currentDocument: linkRef.current?.ownerDocument, }); } else if (external) { const stepName = getNameFromSelector(stepNameSelector); @@ -102,6 +105,7 @@ const InternalLink = React.forwardRef( subStepNameSelector, elementSelector: getFunnelValueSelector(uniqueId), subStepAllSelector: getSubStepAllSelector(), + currentDocument: linkRef.current?.ownerDocument, }); } }; @@ -131,7 +135,6 @@ const InternalLink = React.forwardRef( } }; - const linkRef = useRef(null); const isVisualRefresh = useVisualRefresh(); useForwardFocus(ref, linkRef); diff --git a/src/wizard/analytics.ts b/src/wizard/analytics.ts index e4f55c6688..798f27414a 100644 --- a/src/wizard/analytics.ts +++ b/src/wizard/analytics.ts @@ -1,11 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, RefObject } from 'react'; import { FunnelMetrics } from '../internal/analytics'; import { WizardProps } from './interfaces'; -export function useFunnelChangeEvent(funnelInteractionId: string | undefined, steps: WizardProps['steps']) { +export function useFunnelChangeEvent( + funnelInteractionId: string | undefined, + steps: WizardProps['steps'], + elementRef?: RefObject +) { const listenForStepChanges = useRef(false); useEffect(() => { @@ -27,6 +31,7 @@ export function useFunnelChangeEvent(funnelInteractionId: string | undefined, st FunnelMetrics.funnelChange({ funnelInteractionId, stepConfiguration: getStepConfiguration(steps), + currentDocument: elementRef?.current?.ownerDocument, }); // This dependency array does not include `steps`, because `steps` is not stable across renders. diff --git a/src/wizard/index.tsx b/src/wizard/index.tsx index b54e4bcd5f..17bb5be6e3 100644 --- a/src/wizard/index.tsx +++ b/src/wizard/index.tsx @@ -13,7 +13,7 @@ import { WizardProps } from './interfaces'; import { useFunnel } from '../internal/analytics/hooks/use-funnel'; function Wizard({ isLoadingNextStep = false, allowSkipTo = false, ...props }: WizardProps) { - const baseComponentProps = useBaseComponent('Wizard'); + const baseComponentProps = useBaseComponent('Wizard'); const { wizardCount } = useFunnel(); const externalProps = getExternalProps(props); @@ -31,6 +31,7 @@ function Wizard({ isLoadingNextStep = false, allowSkipTo = false, ...props }: Wi .filter(step => step !== -1)} totalFunnelSteps={props.steps.length} stepConfiguration={getStepConfiguration(props.steps)} + elementRef={baseComponentProps.__internalRootRef} > ; export default function InternalWizard({ steps, @@ -76,6 +76,7 @@ export default function InternalWizard({ stepNameSelector, destinationStepNumber: requestedStepIndex + 1, subStepAllSelector: getSubStepAllSelector(), + currentDocument: __internalRootRef?.current?.ownerDocument, }); } @@ -100,7 +101,7 @@ export default function InternalWizard({ } }; - useFunnelChangeEvent(funnelInteractionId, steps); + useFunnelChangeEvent(funnelInteractionId, steps, __internalRootRef ?? undefined); const i18n = useInternalI18n('wizard'); const skipToButtonLabel = i18n( diff --git a/src/wizard/wizard-form.tsx b/src/wizard/wizard-form.tsx index d5b6a3092b..f6a11b4354 100644 --- a/src/wizard/wizard-form.tsx +++ b/src/wizard/wizard-form.tsx @@ -67,6 +67,7 @@ export default function WizardForm({ {({ funnelStepProps }) => ( <>