diff --git a/src/internal/analytics/__tests__/mocks.ts b/src/internal/analytics/__tests__/mocks.ts index 9e0b05ebe7..b98f2af54c 100644 --- a/src/internal/analytics/__tests__/mocks.ts +++ b/src/internal/analytics/__tests__/mocks.ts @@ -1,7 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { setFunnelMetrics, setPerformanceMetrics } from '../../../../lib/components/internal/analytics'; +import { + setComponentMetrics, + setFunnelMetrics, + setPerformanceMetrics, +} from '../../../../lib/components/internal/analytics'; export const mockedFunnelInteractionId = 'mocked-funnel-id'; export function mockFunnelMetrics() { @@ -33,6 +37,10 @@ export function mockPerformanceMetrics() { }); } +export function mockComponentMetrics() { + setComponentMetrics({ componentMounted: jest.fn(), componentUpdated: jest.fn() }); +} + export function mockInnerText() { if (!('innerText' in HTMLElement.prototype)) { // JSDom does not support the `innerText` property. For tests, `textContent` is usually close enough. diff --git a/src/internal/analytics/index.ts b/src/internal/analytics/index.ts index 716046cb2a..967a7956ad 100644 --- a/src/internal/analytics/index.ts +++ b/src/internal/analytics/index.ts @@ -54,4 +54,6 @@ export let ComponentMetrics: IComponentMetrics = { componentMounted(): string { return ''; }, + + componentUpdated(): void {}, }; diff --git a/src/internal/analytics/interfaces.ts b/src/internal/analytics/interfaces.ts index 9bb213f4be..235f0edd5c 100644 --- a/src/internal/analytics/interfaces.ts +++ b/src/internal/analytics/interfaces.ts @@ -183,14 +183,27 @@ export interface IPerformanceMetrics { modalPerformanceData: ModalPerformanceDataMethod; } +type JSONValue = string | number | boolean | null | undefined; +export interface JSONObject { + [key: string]: JSONObject | JSONValue; +} + export interface ComponentMountedProps { componentName: string; taskInteractionId?: string; - details: Record; + componentConfiguration: JSONObject; +} + +export interface ComponentUpdatedProps extends ComponentMountedProps { + taskInteractionId: string; + actionType: string; } + export type ComponentMountedMethod = (props: ComponentMountedProps) => string; +export type ComponentUpdatedMethod = (props: ComponentUpdatedProps) => void; export interface IComponentMetrics { componentMounted: ComponentMountedMethod; + componentUpdated: ComponentUpdatedMethod; } // Interface for modal metrics diff --git a/src/internal/hooks/use-component-analytics/__tests__/use-component-analytics.test.tsx b/src/internal/hooks/use-component-analytics/__tests__/use-component-analytics.test.tsx deleted file mode 100644 index 1dc1d79e1f..0000000000 --- a/src/internal/hooks/use-component-analytics/__tests__/use-component-analytics.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useRef } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { render } from '@testing-library/react'; - -import { setComponentMetrics } from '../../../analytics'; -import { useComponentAnalytics } from '../index'; - -function Demo() { - const ref = useRef(null); - const { attributes } = useComponentAnalytics('demo', ref, () => ({ - key: 'value', - })); - - return
; -} - -describe('useComponentAnalytics', () => { - let componentMounted: jest.Mock; - - beforeEach(() => { - componentMounted = jest.fn(); - setComponentMetrics({ - componentMounted, - }); - }); - - test('should emit ComponentMount event on mount', () => { - render(); - - expect(componentMounted).toHaveBeenCalledTimes(1); - expect(componentMounted).toHaveBeenCalledWith({ - taskInteractionId: expect.any(String), - componentName: 'demo', - details: { key: 'value' }, - }); - }); - - test('data attribute should be present after the first render', () => { - const { getByTestId } = render(); - - expect(getByTestId('element')).toHaveAttribute('data-analytics-task-interaction-id'); - }); - - test('data attribute should be present after re-rendering', () => { - const { getByTestId, rerender } = render(); - const attributeValueBefore = getByTestId('element').getAttribute('data-analytics-task-interaction-id'); - rerender(); - - expect(getByTestId('element')).toHaveAttribute('data-analytics-task-interaction-id'); - - const attributeValueAfter = getByTestId('element').getAttribute('data-analytics-task-interaction-id'); - expect(attributeValueAfter).toBe(attributeValueBefore); - }); - - test('should not render the attribute during server-side rendering', () => { - const markup = renderToStaticMarkup(); - - expect(markup).toBe('
'); - }); -}); diff --git a/src/internal/hooks/use-component-analytics/index.ts b/src/internal/hooks/use-component-analytics/index.ts deleted file mode 100644 index 8882dd4f5a..0000000000 --- a/src/internal/hooks/use-component-analytics/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { useEffect, useRef } from 'react'; - -import { ComponentMetrics } from '../../analytics'; -import { useRandomId } from '../use-unique-id'; - -function useTaskInteractionAttribute(elementRef: React.RefObject, value: string) { - const attributeName = 'data-analytics-task-interaction-id'; - - const attributeValueRef = useRef(); - - useEffect(() => { - // With this effect, we apply the attribute only on the client, to avoid hydration errors. - attributeValueRef.current = value; - elementRef.current?.setAttribute(attributeName, value); - }, [value, elementRef]); - - return { - [attributeName]: attributeValueRef.current, - }; -} - -export function useComponentAnalytics( - componentName: string, - elementRef: React.RefObject, - getDetails: () => Record -) { - const taskInteractionId = useRandomId(); - const attributes = useTaskInteractionAttribute(elementRef, taskInteractionId); - - useEffect(() => { - ComponentMetrics.componentMounted({ taskInteractionId, componentName, details: getDetails() }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [taskInteractionId]); - - return { taskInteractionId, attributes }; -} diff --git a/src/internal/hooks/use-dom-attribute/index.tsx b/src/internal/hooks/use-dom-attribute/index.tsx new file mode 100644 index 0000000000..ce9e5a19ae --- /dev/null +++ b/src/internal/hooks/use-dom-attribute/index.tsx @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef } from 'react'; + +/* + * This hook allows setting an DOM attribute after the first render, without rerendering the component. + */ +export function useDOMAttribute(elementRef: React.RefObject, attributeName: string, value: string) { + const attributeValueRef = useRef(); + + useEffect(() => { + // With this effect, we apply the attribute only on the client, to avoid hydration errors. + attributeValueRef.current = value; + elementRef.current?.setAttribute(attributeName, value); + }, [attributeName, value, elementRef]); + + return { + [attributeName]: attributeValueRef.current, + }; +} diff --git a/src/internal/hooks/use-performance-marks/index.ts b/src/internal/hooks/use-performance-marks/index.ts index d3283e0a9f..a00d427f64 100644 --- a/src/internal/hooks/use-performance-marks/index.ts +++ b/src/internal/hooks/use-performance-marks/index.ts @@ -1,31 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { useModalContext } from '../../context/modal-context'; +import { useDOMAttribute } from '../use-dom-attribute'; import { useEffectOnUpdate } from '../use-effect-on-update'; import { useRandomId } from '../use-unique-id'; -/* -This hook allows setting an HTML attribute after the first render, without rerendering the component. -*/ -function usePerformanceMarkAttribute(elementRef: React.RefObject, value: string) { - const attributeName = 'data-analytics-performance-mark'; - - const attributeValueRef = useRef(); - - useEffect(() => { - // With this effect, we apply the attribute only on the client, to avoid hydration errors. - attributeValueRef.current = value; - elementRef.current?.setAttribute(attributeName, value); - }, [value, elementRef]); - - return { - [attributeName]: attributeValueRef.current, - }; -} - /** * This function returns an object that needs to be spread onto the same * element as the `elementRef`, so that the data attribute is applied @@ -40,7 +22,8 @@ export function usePerformanceMarks( ) { const id = useRandomId(); const { isInModal } = useModalContext(); - const attributes = usePerformanceMarkAttribute(elementRef, id); + const attributes = useDOMAttribute(elementRef, 'data-analytics-performance-mark', id); + useEffect(() => { if (!enabled || !elementRef.current || isInModal) { return; diff --git a/src/internal/hooks/use-table-interaction-metrics/__tests__/use-table-interaction-metrics.test.tsx b/src/internal/hooks/use-table-interaction-metrics/__tests__/use-table-interaction-metrics.test.tsx index 34c67ff17f..13dd902385 100644 --- a/src/internal/hooks/use-table-interaction-metrics/__tests__/use-table-interaction-metrics.test.tsx +++ b/src/internal/hooks/use-table-interaction-metrics/__tests__/use-table-interaction-metrics.test.tsx @@ -1,6 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { PerformanceMetrics } from '../../../../../lib/components/internal/analytics'; +import React, { createRef, useRef } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { render } from '@testing-library/react'; + +import { + ComponentMetrics, + PerformanceMetrics, + setComponentMetrics, +} from '../../../../../lib/components/internal/analytics'; import { useTableInteractionMetrics, UseTableInteractionMetricsProps, @@ -8,38 +16,92 @@ import { import { renderHook } from '../../../../__tests__/render-hook'; import { mockPerformanceMetrics } from '../../../analytics/__tests__/mocks'; -beforeEach(() => { - jest.resetAllMocks(); - mockPerformanceMetrics(); -}); -jest.useFakeTimers(); - type RenderProps = Partial; -function render(props: RenderProps) { - const defaultProps = { - getComponentIdentifier: () => 'My resources', - itemCount: 10, - loading: undefined, - instanceIdentifier: undefined, - interactionMetadata: () => '', - } satisfies RenderProps; +const defaultProps = { + getComponentConfiguration: () => ({}), + getComponentIdentifier: () => 'My resources', + itemCount: 10, + loading: undefined, + instanceIdentifier: undefined, + interactionMetadata: () => '', +} satisfies RenderProps; + +function renderUseTableInteractionMetricsHook(props: RenderProps) { + const elementRef = createRef(); const { result, rerender, unmount } = renderHook(useTableInteractionMetrics, { - initialProps: { ...defaultProps, ...props }, + initialProps: { elementRef, ...defaultProps, ...props }, }); return { + tableInteractionAttributes: result.current.tableInteractionAttributes, setLastUserAction: (name: string) => result.current.setLastUserAction(name), - rerender: (props: RenderProps) => rerender({ ...defaultProps, ...props }), + rerender: (props: RenderProps) => rerender({ elementRef, ...defaultProps, ...props }), unmount, }; } +function TestComponent(props: RenderProps) { + const elementRef = useRef(null); + const { tableInteractionAttributes } = useTableInteractionMetrics({ elementRef, ...defaultProps, ...props }); + return
; +} + +const componentMounted = jest.fn(); +const componentUpdated = jest.fn(); + +setComponentMetrics({ + componentMounted, + componentUpdated, +}); + +beforeEach(() => { + jest.resetAllMocks(); + mockPerformanceMetrics(); +}); + +jest.useFakeTimers(); + describe('useTableInteractionMetrics', () => { + test('should emit componentMount event on mount', () => { + render(); + + expect(componentMounted).toHaveBeenCalledTimes(1); + expect(componentMounted).toHaveBeenCalledWith({ + taskInteractionId: expect.any(String), + componentName: 'table', + componentConfiguration: {}, + }); + }); + + test('data attribute should be present after the first render', () => { + const { getByTestId } = render(); + jest.runAllTimers(); + + expect(getByTestId('element')).toHaveAttribute('data-analytics-task-interaction-id'); + }); + + test('data attribute should be present after re-rendering', () => { + const { getByTestId, rerender } = render(); + const attributeValueBefore = getByTestId('element').getAttribute('data-analytics-task-interaction-id'); + rerender(); + + expect(getByTestId('element')).toHaveAttribute('data-analytics-task-interaction-id'); + + const attributeValueAfter = getByTestId('element').getAttribute('data-analytics-task-interaction-id'); + expect(attributeValueAfter).toBe(attributeValueBefore); + }); + + test('should not render the attribute during server-side rendering', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toBe('
'); + }); + describe('Interactions', () => { test('user actions should be recorded if they happened recently', () => { - const { setLastUserAction, rerender } = render({}); + const { setLastUserAction, rerender } = renderUseTableInteractionMetricsHook({}); setLastUserAction('filter'); rerender({ loading: true }); @@ -47,6 +109,7 @@ describe('useTableInteractionMetrics', () => { jest.advanceTimersByTime(3456); expect(PerformanceMetrics.tableInteraction).toHaveBeenCalledTimes(0); + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledTimes(0); rerender({ loading: false }); expect(PerformanceMetrics.tableInteraction).toHaveBeenCalledTimes(1); @@ -56,10 +119,18 @@ describe('useTableInteractionMetrics', () => { interactionTime: 3456, }) ); + + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledTimes(1); + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledWith({ + taskInteractionId: expect.any(String), + componentName: 'table', + actionType: 'filter', + componentConfiguration: {}, + }); }); test('user actions should not be recorded if they happened a longer time ago', () => { - const { setLastUserAction, rerender } = render({}); + const { setLastUserAction, rerender } = renderUseTableInteractionMetricsHook({}); setLastUserAction('filter'); @@ -77,7 +148,7 @@ describe('useTableInteractionMetrics', () => { }); test('only the most recent user action should be used', () => { - const { setLastUserAction, rerender } = render({}); + const { setLastUserAction, rerender } = renderUseTableInteractionMetricsHook({}); setLastUserAction('filter'); setLastUserAction('pagination'); @@ -94,7 +165,7 @@ describe('useTableInteractionMetrics', () => { }); test('user actions during the loading state should be ignored', () => { - const { setLastUserAction, rerender } = render({}); + const { setLastUserAction, rerender } = renderUseTableInteractionMetricsHook({}); jest.runAllTimers(); setLastUserAction('filter'); @@ -105,6 +176,13 @@ describe('useTableInteractionMetrics', () => { rerender({ loading: false }); + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledTimes(1); + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: 'filter', + }) + ); + expect(PerformanceMetrics.tableInteraction).toHaveBeenCalledTimes(1); expect(PerformanceMetrics.tableInteraction).toHaveBeenCalledWith( expect.objectContaining({ @@ -114,7 +192,7 @@ describe('useTableInteractionMetrics', () => { }); test('interactionMetadata is added to the performance metrics', () => { - const { setLastUserAction, rerender } = render({}); + const { setLastUserAction, rerender } = renderUseTableInteractionMetricsHook({}); const interactionMetadataValue = '{filterText = test}'; setLastUserAction('filter'); rerender({ loading: true }); @@ -133,5 +211,24 @@ describe('useTableInteractionMetrics', () => { }) ); }); + + test('componentConfiguration is added to the component updated metrics', () => { + const { setLastUserAction, rerender } = renderUseTableInteractionMetricsHook({}); + const componentConfiguration = { filterText: 'test' }; + setLastUserAction('filter'); + rerender({ loading: true }); + rerender({ + loading: false, + getComponentConfiguration: () => componentConfiguration, + }); + + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledTimes(1); + expect(ComponentMetrics.componentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: 'filter', + componentConfiguration, + }) + ); + }); }); }); diff --git a/src/internal/hooks/use-table-interaction-metrics/index.ts b/src/internal/hooks/use-table-interaction-metrics/index.ts index 1b280aaaa9..4508d8db3d 100644 --- a/src/internal/hooks/use-table-interaction-metrics/index.ts +++ b/src/internal/hooks/use-table-interaction-metrics/index.ts @@ -3,8 +3,11 @@ import { useEffect, useRef } from 'react'; -import { PerformanceMetrics } from '../../analytics'; +import { ComponentMetrics, PerformanceMetrics } from '../../analytics'; +import { JSONObject } from '../../analytics/interfaces'; +import { useDOMAttribute } from '../use-dom-attribute'; import { useEffectOnUpdate } from '../use-effect-on-update'; +import { useRandomId } from '../use-unique-id'; /* If the last user interaction is more than this time ago, it is not considered @@ -13,26 +16,45 @@ to be the cause of the current loading state. const USER_ACTION_TIME_LIMIT = 1_000; export interface UseTableInteractionMetricsProps { + elementRef: React.RefObject; instanceIdentifier: string | undefined; loading: boolean | undefined; itemCount: number; getComponentIdentifier: () => string | undefined; + getComponentConfiguration: () => JSONObject; interactionMetadata: () => string; } export function useTableInteractionMetrics({ + elementRef, itemCount, instanceIdentifier, getComponentIdentifier, + getComponentConfiguration, loading = false, interactionMetadata, }: UseTableInteractionMetricsProps) { + const taskInteractionId = useRandomId(); + const tableInteractionAttributes = useDOMAttribute( + elementRef, + 'data-analytics-task-interaction-id', + taskInteractionId + ); const lastUserAction = useRef<{ name: string; time: number } | null>(null); const capturedUserAction = useRef(null); const loadingStartTime = useRef(null); - const metadata = useRef({ itemCount, getComponentIdentifier, interactionMetadata }); - metadata.current = { itemCount, getComponentIdentifier, interactionMetadata }; + const metadata = useRef({ itemCount, getComponentIdentifier, getComponentConfiguration, interactionMetadata }); + metadata.current = { itemCount, getComponentIdentifier, getComponentConfiguration, interactionMetadata }; + + useEffect(() => { + ComponentMetrics.componentMounted({ + taskInteractionId, + componentName: 'table', + componentConfiguration: metadata.current.getComponentConfiguration(), + }); + }, [taskInteractionId]); + useEffect(() => { if (loading) { loadingStartTime.current = performance.now(); @@ -58,10 +80,18 @@ export function useTableInteractionMetrics({ instanceIdentifier, noOfResourcesInTable: metadata.current.itemCount, }); + + ComponentMetrics.componentUpdated({ + taskInteractionId, + componentName: 'table', + actionType: capturedUserAction.current ?? '', + componentConfiguration: metadata.current.getComponentConfiguration(), + }); } - }, [instanceIdentifier, loading]); + }, [instanceIdentifier, loading, taskInteractionId]); return { + tableInteractionAttributes, setLastUserAction: (name: string) => void (lastUserAction.current = { name, time: performance.now() }), }; } diff --git a/src/table/internal.tsx b/src/table/internal.tsx index de89d4f6b4..f9836be4a8 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -20,7 +20,6 @@ import { LinkDefaultVariantContext } from '../internal/context/link-default-vari import { FilterRef, PaginationRef, TableComponentsContext } from '../internal/context/table-component-context'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; -import { useComponentAnalytics } from '../internal/hooks/use-component-analytics'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; import { useMobile } from '../internal/hooks/use-mobile'; import useMouseDownTarget from '../internal/hooks/use-mouse-down-target'; @@ -210,14 +209,6 @@ const InternalTable = React.forwardRef( ); const analyticsMetadata = getAnalyticsMetadataProps(rest); - const { attributes: componentAnalyticsAttributes } = useComponentAnalytics('table', tableRefObject, () => ({ - variant, - flowType: rest.analyticsMetadata?.flowType, - instanceIdentifier: analyticsMetadata?.instanceIdentifier, - taskName: getHeaderText(), - patternIdentifier: getPatternIdentifier(), - })); - const interactionMetadata = () => { const filterData = filterRef.current; const paginationData = paginationRef.current; @@ -228,12 +219,35 @@ const InternalTable = React.forwardRef( sortingOrder: sortingColumn ? (sortingDescending ? 'Descending' : 'Ascending') : undefined, }); }; + const getComponentConfiguration = () => { + const filterData = filterRef.current; + const paginationData = paginationRef.current; + + return { + variant, + flowType: rest.analyticsMetadata?.flowType, + instanceIdentifier: analyticsMetadata?.instanceIdentifier, + taskName: getHeaderText(), + patternIdentifier: getPatternIdentifier(), + sortedBy: { + columnId: sortingColumn?.sortingField, + sortingOrder: sortingColumn ? (sortingDescending ? 'desc' : 'asc') : undefined, + }, + filtered: Boolean(filterData?.filterText), + currentPageIndex: paginationData.currentPageIndex, + totalNumberOfResources: paginationData.totalPageCount, + resourcesPerPage: allRows?.length || 0, + resourcesSelected: selectedItems?.length > 0, + }; + }; - const { setLastUserAction } = useTableInteractionMetrics({ + const { setLastUserAction, tableInteractionAttributes } = useTableInteractionMetrics({ + elementRef: tableRefObject, loading, instanceIdentifier: analyticsMetadata?.instanceIdentifier, itemCount: items.length, getComponentIdentifier: getHeaderText, + getComponentConfiguration, interactionMetadata, }); @@ -488,7 +502,7 @@ const InternalTable = React.forwardRef( >