diff --git a/packages/contact-center/ui-logging/src/metricsLogger.ts b/packages/contact-center/ui-logging/src/metricsLogger.ts index 02f2b8d87..35122e9b5 100644 --- a/packages/contact-center/ui-logging/src/metricsLogger.ts +++ b/packages/contact-center/ui-logging/src/metricsLogger.ts @@ -44,6 +44,34 @@ export const logMetrics = (metric: WidgetMetrics) => { }); }; +/** + * Identifies which watched props have changed between two objects. + * Only checks the keys specified in propsToWatch to avoid logging noise + * from frequently-changing props like timers. + * + * @param prev - The previous props object + * @param next - The next props object + * @param propsToWatch - Array of prop keys to monitor for changes + * @returns Record of changed prop keys with their old and new values, or null if no watched props changed + */ +export function getChangedWatchedProps( + prev: Record, + next: Record, + propsToWatch: string[] +): Record | null { + if (!propsToWatch.length || !prev || !next) return null; + + const changes: Record = {}; + + for (const key of propsToWatch) { + if (prev[key] !== next[key]) { + changes[key] = {oldValue: prev[key], newValue: next[key]}; + } + } + + return Object.keys(changes).length > 0 ? changes : null; +} + /** * Determines if props have changed between two objects using shallow comparison. * diff --git a/packages/contact-center/ui-logging/src/withMetrics.tsx b/packages/contact-center/ui-logging/src/withMetrics.tsx index 77354d3dd..0da765697 100644 --- a/packages/contact-center/ui-logging/src/withMetrics.tsx +++ b/packages/contact-center/ui-logging/src/withMetrics.tsx @@ -1,9 +1,15 @@ import React, {useEffect, useRef} from 'react'; -import {havePropsChanged, logMetrics} from './metricsLogger'; +import {getChangedWatchedProps, havePropsChanged, logMetrics} from './metricsLogger'; -export default function withMetrics

(Component: any, widgetName: string) { +export default function withMetrics

( + Component: any, + widgetName: string, + propsToWatch: (keyof P & string)[] = [] +) { return React.memo( (props: P) => { + const prevPropsRef = useRef

(null); + useEffect(() => { logMetrics({ widgetName, @@ -20,7 +26,24 @@ export default function withMetrics

(Component: any, widgetName }; }, []); - // TODO: https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6890 PROPS_UPDATED event + useEffect(() => { + if (prevPropsRef.current && propsToWatch.length > 0) { + const changes = getChangedWatchedProps( + prevPropsRef.current as Record, + props as Record, + propsToWatch + ); + if (changes) { + logMetrics({ + widgetName, + event: 'PROPS_UPDATED', + props: changes, + timestamp: Date.now(), + }); + } + } + prevPropsRef.current = props; + }); return ; }, diff --git a/packages/contact-center/ui-logging/tests/metricsLogger.test.ts b/packages/contact-center/ui-logging/tests/metricsLogger.test.ts index f7ecf15e5..6cc2f2717 100644 --- a/packages/contact-center/ui-logging/tests/metricsLogger.test.ts +++ b/packages/contact-center/ui-logging/tests/metricsLogger.test.ts @@ -1,5 +1,5 @@ import store from '@webex/cc-store'; -import {logMetrics, havePropsChanged, WidgetMetrics} from '../src/metricsLogger'; +import {logMetrics, havePropsChanged, getChangedWatchedProps, WidgetMetrics} from '../src/metricsLogger'; describe('metricsLogger', () => { store.store.logger = { @@ -82,5 +82,161 @@ describe('metricsLogger', () => { expect(havePropsChanged(undefined, undefined)).toBe(false); expect(havePropsChanged(null, undefined)).toBe(true); }); + + it('should return false for the same object reference', () => { + const obj = {a: 1, b: 2}; + expect(havePropsChanged(obj, obj)).toBe(false); + }); + + it('should return true when primitive value changes in flat object', () => { + const prev = {name: 'John', age: 30}; + const next = {name: 'John', age: 31}; + expect(havePropsChanged(prev, next)).toBe(true); + }); + + it('should return false for objects with same primitive values', () => { + const prev = {name: 'John', age: 30}; + const next = {name: 'John', age: 30}; + expect(havePropsChanged(prev, next)).toBe(false); + }); + + it('should return true when a value changes from object to null', () => { + const prev = {a: {nested: true}}; + const next = {a: null}; + expect(havePropsChanged(prev, next)).toBe(true); + }); + + it('should return true when a value changes from null to object', () => { + const prev = {a: null}; + const next = {a: {nested: true}}; + expect(havePropsChanged(prev, next)).toBe(true); + }); + + it('should return true when next has more keys than prev', () => { + const prev = {a: 1}; + const next = {a: 1, b: 2}; + expect(havePropsChanged(prev, next)).toBe(true); + }); + + it('should handle empty objects', () => { + expect(havePropsChanged({}, {})).toBe(false); + }); + + it('should return false when both arrays are different references but same nested objects', () => { + const prev = {items: [1, 2, 3]}; + const next = {items: [1, 2, 4]}; + expect(havePropsChanged(prev, next)).toBe(false); + }); + + it('should return true when function references differ', () => { + const prev = {onClick: () => {}}; + const next = {onClick: () => {}}; + expect(havePropsChanged(prev, next)).toBe(true); + }); + + it('should return false when function reference is the same', () => { + const fn = () => {}; + const prev = {onClick: fn}; + const next = {onClick: fn}; + expect(havePropsChanged(prev, next)).toBe(false); + }); + + it('should return true when a primitive changes to undefined', () => { + const prev = {a: 'hello'}; + const next = {a: undefined}; + expect(havePropsChanged(prev, next)).toBe(true); + }); + }); + + describe('getChangedWatchedProps', () => { + it('should return null when propsToWatch is empty', () => { + expect(getChangedWatchedProps({a: 1}, {a: 2}, [])).toBeNull(); + }); + + it('should return null when prev or next is null/undefined', () => { + expect(getChangedWatchedProps(null, {a: 1}, ['a'])).toBeNull(); + expect(getChangedWatchedProps({a: 1}, null, ['a'])).toBeNull(); + }); + + it('should return null when watched props have not changed', () => { + const prev = {name: 'John', timer: 10, age: 30}; + const next = {name: 'John', timer: 20, age: 30}; + expect(getChangedWatchedProps(prev, next, ['name', 'age'])).toBeNull(); + }); + + it('should return changes for watched props that changed', () => { + const prev = {name: 'John', timer: 10, age: 30}; + const next = {name: 'Jane', timer: 20, age: 31}; + const result = getChangedWatchedProps(prev, next, ['name', 'age']); + expect(result).toEqual({ + name: {oldValue: 'John', newValue: 'Jane'}, + age: {oldValue: 30, newValue: 31}, + }); + }); + + it('should only report changes for watched props, ignoring unwatched', () => { + const prev = {name: 'John', timer: 10}; + const next = {name: 'John', timer: 20}; + expect(getChangedWatchedProps(prev, next, ['name'])).toBeNull(); + }); + + it('should handle watched props that do not exist on objects', () => { + const prev = {name: 'John'}; + const next = {name: 'John'}; + expect(getChangedWatchedProps(prev, next, ['name', 'missing'])).toBeNull(); + }); + + it('should detect when a watched prop changes from undefined to a value', () => { + const prev = {name: undefined}; + const next = {name: 'John'}; + const result = getChangedWatchedProps(prev, next, ['name']); + expect(result).toEqual({ + name: {oldValue: undefined, newValue: 'John'}, + }); + }); + + it('should detect when a watched prop changes from a value to undefined', () => { + const prev = {name: 'John'}; + const next = {name: undefined}; + const result = getChangedWatchedProps(prev, next, ['name']); + expect(result).toEqual({ + name: {oldValue: 'John', newValue: undefined}, + }); + }); + + it('should return only the changed watched prop when multiple are watched', () => { + const prev = {name: 'John', status: 'active', role: 'admin'}; + const next = {name: 'John', status: 'inactive', role: 'admin'}; + const result = getChangedWatchedProps(prev, next, ['name', 'status', 'role']); + expect(result).toEqual({ + status: {oldValue: 'active', newValue: 'inactive'}, + }); + }); + + it('should detect changes for boolean watched props', () => { + const prev = {isActive: true, name: 'John'}; + const next = {isActive: false, name: 'John'}; + const result = getChangedWatchedProps(prev, next, ['isActive']); + expect(result).toEqual({ + isActive: {oldValue: true, newValue: false}, + }); + }); + + it('should detect changes for numeric watched props', () => { + const prev = {count: 0, name: 'John'}; + const next = {count: 5, name: 'John'}; + const result = getChangedWatchedProps(prev, next, ['count']); + expect(result).toEqual({ + count: {oldValue: 0, newValue: 5}, + }); + }); + + it('should return null when both prev and next are null', () => { + expect(getChangedWatchedProps(null, null, ['a'])).toBeNull(); + }); + + it('should return null when both prev and next are undefined', () => { + expect(getChangedWatchedProps(undefined, undefined, ['a'])).toBeNull(); + }); }); }); diff --git a/packages/contact-center/ui-logging/tests/withMetrics.test.tsx b/packages/contact-center/ui-logging/tests/withMetrics.test.tsx index 23ec71691..ac29f8aa5 100644 --- a/packages/contact-center/ui-logging/tests/withMetrics.test.tsx +++ b/packages/contact-center/ui-logging/tests/withMetrics.test.tsx @@ -102,4 +102,175 @@ describe('withMetrics HOC', () => { rerender(); expect(renderSpy).toHaveBeenCalledTimes(2); }); + + it('should log PROPS_UPDATED when watched props change', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const SpyComponent: React.FC = (props) =>

Test {props.name}
; + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget', ['name']); + + const {rerender} = render(); + logMetricsSpy.mockClear(); + + rerender(); + + expect(logMetricsSpy).toHaveBeenCalledWith({ + widgetName: 'TestWidget', + event: 'PROPS_UPDATED', + props: {name: {oldValue: 'old', newValue: 'new'}}, + timestamp: mockTime, + }); + }); + + it('should not log PROPS_UPDATED when unwatched props change', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const SpyComponent: React.FC = (props) =>
Test {props.name}
; + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget', ['name']); + + const {rerender} = render(); + logMetricsSpy.mockClear(); + + rerender(); + + expect(logMetricsSpy).not.toHaveBeenCalledWith( + expect.objectContaining({event: 'PROPS_UPDATED'}) + ); + }); + + it('should not log PROPS_UPDATED when propsToWatch is empty', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const SpyComponent: React.FC = (props) =>
Test {props.name}
; + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget'); + + const {rerender} = render(); + logMetricsSpy.mockClear(); + + rerender(); + + expect(logMetricsSpy).not.toHaveBeenCalledWith( + expect.objectContaining({event: 'PROPS_UPDATED'}) + ); + }); + + it('should log PROPS_UPDATED for multiple watched props that change simultaneously', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + interface MultiPropComponentProps { + name?: string; + status?: string; + count?: number; + [key: string]: any; + } + + const SpyComponent: React.FC = (props) => ( +
+ {props.name} {props.status} {props.count} +
+ ); + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget', ['name', 'status']); + + const {rerender} = render(); + logMetricsSpy.mockClear(); + + rerender(); + + expect(logMetricsSpy).toHaveBeenCalledWith({ + widgetName: 'TestWidget', + event: 'PROPS_UPDATED', + props: { + name: {oldValue: 'old', newValue: 'new'}, + status: {oldValue: 'active', newValue: 'inactive'}, + }, + timestamp: mockTime, + }); + }); + + it('should only log changed watched props when some watched props stay the same', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const SpyComponent: React.FC = (props) =>
Test {props.name}
; + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget', ['name']); + + const {rerender} = render(); + logMetricsSpy.mockClear(); + + rerender(); + + expect(logMetricsSpy).not.toHaveBeenCalled(); + }); + + it('should not log PROPS_UPDATED on first render', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const SpyComponent: React.FC = (props) =>
Test {props.name}
; + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget', ['name']); + + render(); + + expect(logMetricsSpy).toHaveBeenCalledTimes(1); + expect(logMetricsSpy).toHaveBeenCalledWith( + expect.objectContaining({event: 'WIDGET_MOUNTED'}) + ); + expect(logMetricsSpy).not.toHaveBeenCalledWith( + expect.objectContaining({event: 'PROPS_UPDATED'}) + ); + }); + + it('should log correct widget name for different wrapped components', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const ComponentA: React.FC = () =>
A
; + const ComponentB: React.FC = () =>
B
; + + const WrappedA = withMetrics(ComponentA, 'WidgetA'); + const WrappedB = withMetrics(ComponentB, 'WidgetB'); + + render(); + expect(logMetricsSpy).toHaveBeenCalledWith( + expect.objectContaining({widgetName: 'WidgetA', event: 'WIDGET_MOUNTED'}) + ); + + logMetricsSpy.mockClear(); + render(); + expect(logMetricsSpy).toHaveBeenCalledWith( + expect.objectContaining({widgetName: 'WidgetB', event: 'WIDGET_MOUNTED'}) + ); + }); + + it('should track prop changes across multiple re-renders', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const SpyComponent: React.FC = (props) =>
Test {props.name}
; + const WrappedSpy = withMetrics(SpyComponent, 'TestWidget', ['name']); + + const {rerender} = render(); + logMetricsSpy.mockClear(); + + rerender(); + expect(logMetricsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'PROPS_UPDATED', + props: {name: {oldValue: 'first', newValue: 'second'}}, + }) + ); + + logMetricsSpy.mockClear(); + rerender(); + expect(logMetricsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'PROPS_UPDATED', + props: {name: {oldValue: 'second', newValue: 'third'}}, + }) + ); + }); });