diff --git a/jest.config.js b/jest.config.js index 6b871d1d7..a7f9e419e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,7 +9,7 @@ module.exports = { testEnvironment: 'jsdom', testMatch: ['**/tooling/tests/**/*.js'], transformIgnorePatterns: [ - '/node_modules/(?!(@momentum-design/components|@momentum-ui/react-collaboration|@lit|lit|cheerio))', + '/node_modules/(?!(@momentum-design/components|@momentum-ui/react-collaboration|@lit|lit|cheerio|react-error-boundary))', ], // Use babel-jest or ts-jest depending on your setup transform: { diff --git a/packages/contact-center/cc-components/package.json b/packages/contact-center/cc-components/package.json index 1086e4a4d..6291f9d5d 100644 --- a/packages/contact-center/cc-components/package.json +++ b/packages/contact-center/cc-components/package.json @@ -76,4 +76,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx b/packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx index e75da3564..5d61f896f 100644 --- a/packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx +++ b/packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx @@ -57,12 +57,11 @@ const StationLoginComponent: React.FunctionComponent const [showCCSignOutModal, setShowCCSignOutModal] = useState(false); const [showDNError, setShowDNError] = useState(false); const [dnErrorText, setDNErrorText] = useState(''); - const {multiSignInModalRef, ccSignOutModalRef, saveConfirmDialogRef} = createStationLoginRefs(); + const {multiSignInModalRef, ccSignOutModalRef, saveConfirmDialogRef} = createStationLoginRefs(logger); useEffect(() => { if (deviceType !== DESKTOP && Object.keys(LoginOptions).includes(deviceType)) { - setDialNumberLabel(LoginOptions[deviceType]); - setDialNumberPlaceholder(LoginOptions[deviceType]); + updateDialNumberLabel(deviceType, setDialNumberLabel, setDialNumberPlaceholder, logger); } }, [selectedDeviceType, isAgentLoggedIn]); @@ -74,7 +73,8 @@ const StationLoginComponent: React.FunctionComponent saveConfirmDialogRef, showMultipleLoginAlert, showCCSignOutModal, - showSaveConfirmDialog + showSaveConfirmDialog, + logger ); }, [showMultipleLoginAlert, showCCSignOutModal, showSaveConfirmDialog]); @@ -92,7 +92,7 @@ const StationLoginComponent: React.FunctionComponent @@ -145,7 +145,7 @@ const StationLoginComponent: React.FunctionComponent type="button" role="button" aria-label="Close" - onClick={() => saveConfirmCancelClicked(saveConfirmDialogRef, setShowSaveConfirmDialog)} + onClick={() => saveConfirmCancelClicked(saveConfirmDialogRef, setShowSaveConfirmDialog, logger)} className="cancelSaveLoginOptions" > @@ -154,13 +154,15 @@ const StationLoginComponent: React.FunctionComponent
-
diff --git a/packages/contact-center/cc-components/src/components/StationLogin/station-login.utils.tsx b/packages/contact-center/cc-components/src/components/StationLogin/station-login.utils.tsx index 58d740f24..80b0ecd3c 100644 --- a/packages/contact-center/cc-components/src/components/StationLogin/station-login.utils.tsx +++ b/packages/contact-center/cc-components/src/components/StationLogin/station-login.utils.tsx @@ -8,23 +8,32 @@ const handleModals = ( saveConfirmDialogRef, showMultipleLoginAlert, showCCSignOutModal, - showSaveConfirmDialog + showSaveConfirmDialog, + logger ) => { - const modalStates = [ - {ref: modalRef, show: showMultipleLoginAlert}, - {ref: ccSignOutModalRef, show: showCCSignOutModal}, - {ref: saveConfirmDialogRef, show: showSaveConfirmDialog}, - ]; - - modalStates.forEach(({ref, show}) => { - if (ref?.current) { - if (show && !ref.current.open) { - ref.current.showModal(); - } else if (!show && ref.current.open) { - ref.current.close(); + try { + const modalStates = [ + {ref: modalRef, show: showMultipleLoginAlert}, + {ref: ccSignOutModalRef, show: showCCSignOutModal}, + {ref: saveConfirmDialogRef, show: showSaveConfirmDialog}, + ]; + + modalStates.forEach(({ref, show}) => { + if (ref?.current) { + if (show && !ref.current.open) { + ref.current.showModal(); + } else if (!show && ref.current.open) { + ref.current.close(); + } } - } - }); + }); + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleModals', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleModals', + error: error.message, + }); + } }; /** * Handler for the Contact Center modal continue button @@ -35,11 +44,19 @@ const handleModals = ( * Closes the dialog if it is currently open and calls the provided callback function */ -const continueClicked = (modalRef, callback, setShowCCSignOutModal) => { - setShowCCSignOutModal(false); - if (modalRef.current) { - modalRef.current.close(); - callback(); +const continueClicked = (modalRef, callback, setShowCCSignOutModal, logger) => { + try { + setShowCCSignOutModal(false); + if (modalRef.current) { + modalRef.current.close(); + callback(); + } + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in continueClicked', { + module: 'cc-components#station-login.utils.tsx', + method: 'continueClicked', + error: error.message, + }); } }; @@ -50,22 +67,40 @@ const continueClicked = (modalRef, callback, setShowCCSignOutModal) => { */ const ccCancelButtonClicked = ( ccSignOutModalRef: React.RefObject, - setShowCCSignOutModal: (show: boolean) => void + setShowCCSignOutModal: (show: boolean) => void, + logger ) => { - if (ccSignOutModalRef?.current?.open) { - ccSignOutModalRef.current.close(); - setShowCCSignOutModal(false); + try { + if (ccSignOutModalRef?.current?.open) { + ccSignOutModalRef.current.close(); + setShowCCSignOutModal(false); + } + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in ccCancelButtonClicked', { + module: 'cc-components#station-login.utils.tsx', + method: 'ccCancelButtonClicked', + error: error.message, + }); } }; const updateDialNumberLabel = ( selectedOption: string, setDialNumberLabel: (label: string) => void, - setDialNumberPlaceholder: (placeholder: string) => void + setDialNumberPlaceholder: (placeholder: string) => void, + logger ): void => { - if (selectedOption !== DESKTOP && Object.keys(LoginOptions).includes(selectedOption)) { - setDialNumberLabel(LoginOptions[selectedOption]); - setDialNumberPlaceholder(LoginOptions[selectedOption]); + try { + if (selectedOption !== DESKTOP && Object.keys(LoginOptions).includes(selectedOption)) { + setDialNumberLabel(LoginOptions[selectedOption]); + setDialNumberPlaceholder(LoginOptions[selectedOption]); + } + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in updateDialNumberLabel', { + module: 'cc-components#station-login.utils.tsx', + method: 'updateDialNumberLabel', + error: error.message, + }); } }; @@ -77,34 +112,74 @@ const updateDialNumberLabel = ( const validateDialNumber = ( input: string, dialNumberRegex: null | string, - setDNErrorText: (error: string) => void + setDNErrorText: (error: string) => void, + logger ): boolean => { - const regexForDn = new RegExp(dialNumberRegex ?? '1[0-9]{3}[2-9][0-9]{6}([,]{1,10}[0-9]+){0,1}'); - if (regexForDn.test(input)) { - return false; + try { + const regexForDn = new RegExp(dialNumberRegex ?? '1[0-9]{3}[2-9][0-9]{6}([,]{1,10}[0-9]+){0,1}'); + if (regexForDn.test(input)) { + return false; + } + setDNErrorText(StationLoginLabels.DN_FORMAT_ERROR); + return true; + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in validateDialNumber', { + module: 'cc-components#station-login.utils.tsx', + method: 'validateDialNumber', + error: error.message, + }); + setDNErrorText(StationLoginLabels.DN_FORMAT_ERROR); + return true; } - setDNErrorText(StationLoginLabels.DN_FORMAT_ERROR); - return true; }; -const createStationLoginRefs = () => { - return { - multiSignInModalRef: useRef(null), - ccSignOutModalRef: useRef(null), - saveConfirmDialogRef: useRef(null), - }; +const createStationLoginRefs = (logger) => { + try { + return { + multiSignInModalRef: useRef(null), + ccSignOutModalRef: useRef(null), + saveConfirmDialogRef: useRef(null), + }; + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in createStationLoginRefs', { + module: 'cc-components#station-login.utils.tsx', + method: 'createStationLoginRefs', + error: error.message, + }); + return { + multiSignInModalRef: useRef(null), + ccSignOutModalRef: useRef(null), + saveConfirmDialogRef: useRef(null), + }; + } }; -const saveConfirmCancelClicked = (saveConfirmDialogRef, setShowSaveConfirmDialog) => { - if (saveConfirmDialogRef?.current?.open) { - saveConfirmDialogRef.current.close(); - setShowSaveConfirmDialog(false); +const saveConfirmCancelClicked = (saveConfirmDialogRef, setShowSaveConfirmDialog, logger) => { + try { + if (saveConfirmDialogRef?.current?.open) { + saveConfirmDialogRef.current.close(); + setShowSaveConfirmDialog(false); + } + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in saveConfirmCancelClicked', { + module: 'cc-components#station-login.utils.tsx', + method: 'saveConfirmCancelClicked', + error: error.message, + }); } }; -const handleSaveConfirm = (saveConfirmDialogRef, setShowSaveConfirmDialog, saveLoginOptions) => { - saveConfirmCancelClicked(saveConfirmDialogRef, setShowSaveConfirmDialog); - saveLoginOptions(); +const handleSaveConfirm = (saveConfirmDialogRef, setShowSaveConfirmDialog, saveLoginOptions, logger) => { + try { + saveConfirmCancelClicked(saveConfirmDialogRef, setShowSaveConfirmDialog, logger); + saveLoginOptions(); + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleSaveConfirm', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleSaveConfirm', + error: error.message, + }); + } }; const handleLoginOptionChanged = ( @@ -124,41 +199,49 @@ const handleLoginOptionChanged = ( logger, selectedTeamId ) => { - const selectedOption = event.detail.value; - logger.info(`CC-Widgets: StationLogin: login option changed to: ${selectedOption}`, { - module: 'cc-components#station-login.tsx', - method: 'loginOptionChanged', - }); - // TODO: Select component is calling onChange with first label on load - // bug ticket: https://jira-eng-gpk2.cisco.com/jira/browse/MOMENTUM-668 - if (Object.keys(LoginOptions).includes(selectedOption)) { - setDeviceType(selectedOption); - setSelectedDeviceType(selectedOption); - updateDialNumberLabel(selectedOption, setDialNumberLabel, setDialNumberPlaceholder); - // clear dial number when switching between DN and Extension - setDialNumber(''); - setShowDNError(false); - // If switching to the device type the user logged in with, restore its value - if (selectedOption === originalLoginOptions.deviceType) { - setCurrentLoginOptions({ - deviceType: selectedOption, - dialNumber: originalLoginOptions.dialNumber || '', - teamId: originalLoginOptions.teamId || '', - }); - setDialNumberValue(originalLoginOptions.dialNumber || ''); - setDialNumber(originalLoginOptions.dialNumber || ''); - setSelectedTeamId(originalLoginOptions.teamId || ''); - setTeamId(originalLoginOptions.teamId || ''); - } else { - // If switching to a different device type, clear the input - setCurrentLoginOptions({ - deviceType: selectedOption, - dialNumber: '', - teamId: selectedTeamId || '', - }); - setDialNumberValue(''); + try { + const selectedOption = event.detail.value; + logger.info(`CC-Widgets: StationLogin: login option changed to: ${selectedOption}`, { + module: 'cc-components#station-login.tsx', + method: 'loginOptionChanged', + }); + // TODO: Select component is calling onChange with first label on load + // bug ticket: https://jira-eng-gpk2.cisco.com/jira/browse/MOMENTUM-668 + if (Object.keys(LoginOptions).includes(selectedOption)) { + setDeviceType(selectedOption); + setSelectedDeviceType(selectedOption); + updateDialNumberLabel(selectedOption, setDialNumberLabel, setDialNumberPlaceholder, logger); + // clear dial number when switching between DN and Extension setDialNumber(''); + setShowDNError(false); + // If switching to the device type the user logged in with, restore its value + if (selectedOption === originalLoginOptions.deviceType) { + setCurrentLoginOptions({ + deviceType: selectedOption, + dialNumber: originalLoginOptions.dialNumber || '', + teamId: originalLoginOptions.teamId || '', + }); + setDialNumberValue(originalLoginOptions.dialNumber || ''); + setDialNumber(originalLoginOptions.dialNumber || ''); + setSelectedTeamId(originalLoginOptions.teamId || ''); + setTeamId(originalLoginOptions.teamId || ''); + } else { + // If switching to a different device type, clear the input + setCurrentLoginOptions({ + deviceType: selectedOption, + dialNumber: '', + teamId: selectedTeamId || '', + }); + setDialNumberValue(''); + setDialNumber(''); + } } + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleLoginOptionChanged', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleLoginOptionChanged', + error: error.message, + }); } }; @@ -173,60 +256,93 @@ const handleDNInputChanged = ( selectedDeviceType, logger ) => { - const input = (event.target as HTMLInputElement).value.trim(); - logger.info(`CC-Widgets: StationLogin: dialNumber input changed: ${input}`, { - module: 'cc-components#station-login.tsx', - method: 'dialNumberInputChanged', - }); - setDialNumberValue(input); - setDialNumber(input); - - // validation - if (input.length === 0) { - // show error for empty string - setDNErrorText(`${LoginOptions[selectedDeviceType]} ${StationLoginLabels.IS_REQUIRED}`); - setShowDNError(true); - } else if (selectedDeviceType === DIALNUMBER) { - setShowDNError(validateDialNumber(input, dialNumberRegex, setDNErrorText)); - } else { - setShowDNError(false); + try { + const input = (event.target as HTMLInputElement).value.trim(); + logger.info(`CC-Widgets: StationLogin: dialNumber input changed: ${input}`, { + module: 'cc-components#station-login.tsx', + method: 'dialNumberInputChanged', + }); + setDialNumberValue(input); + setDialNumber(input); + + // validation + if (input.length === 0) { + // show error for empty string + setDNErrorText(`${LoginOptions[selectedDeviceType]} ${StationLoginLabels.IS_REQUIRED}`); + setShowDNError(true); + } else if (selectedDeviceType === DIALNUMBER) { + setShowDNError(validateDialNumber(input, dialNumberRegex, setDNErrorText, logger)); + } else { + setShowDNError(false); + } + setCurrentLoginOptions((prev) => { + return { + ...prev, + dialNumber: input, + }; + }); + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleDNInputChanged', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleDNInputChanged', + error: error.message, + }); } - setCurrentLoginOptions((prev) => { - return { - ...prev, - dialNumber: input, - }; - }); }; const handleTeamSelectChanged = (event, setSelectedTeamId, setTeamId, setCurrentLoginOptions, setTeam, logger) => { - const value = event.detail.value; - logger.info(`CC-Widgets: StationLogin: team selected: ${value}`, { - module: 'cc-components#station-login.tsx', - method: 'teamSelected', - }); - setTeam(value); - setSelectedTeamId(event.detail.value); - setTeamId(event.detail.value); - setCurrentLoginOptions((prev) => ({ - ...prev, - teamId: value, - })); + try { + const value = event.detail.value; + logger.info(`CC-Widgets: StationLogin: team selected: ${value}`, { + module: 'cc-components#station-login.tsx', + method: 'teamSelected', + }); + setTeam(value); + setSelectedTeamId(event.detail.value); + setTeamId(event.detail.value); + setCurrentLoginOptions((prev) => ({ + ...prev, + teamId: value, + })); + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleTeamSelectChanged', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleTeamSelectChanged', + error: error.message, + }); + } }; -const handleOnCCSignOut = (ccSignOutModalRef: React.RefObject, onCCSignOut) => { - if (ccSignOutModalRef?.current?.open) { - ccSignOutModalRef.current.close(); +const handleOnCCSignOut = (ccSignOutModalRef: React.RefObject, onCCSignOut, logger) => { + try { + if (ccSignOutModalRef?.current?.open) { + ccSignOutModalRef.current.close(); + } + onCCSignOut(); + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleOnCCSignOut', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleOnCCSignOut', + error: error.message, + }); } - onCCSignOut(); }; const handleCCSignoutKeyDown = ( event: React.KeyboardEvent, - setShowCCSignOutModal: (show: boolean) => void + setShowCCSignOutModal: (show: boolean) => void, + logger ) => { - if (event.key === 'Escape') { - setShowCCSignOutModal(false); + try { + if (event.key === 'Escape') { + setShowCCSignOutModal(false); + } + } catch (error) { + logger?.error('CC-Widgets: StationLogin: Error in handleCCSignoutKeyDown', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleCCSignoutKeyDown', + error: error.message, + }); } }; diff --git a/packages/contact-center/cc-components/src/components/UserState/user-state.tsx b/packages/contact-center/cc-components/src/components/UserState/user-state.tsx index 0a43f21e7..0c34adf23 100644 --- a/packages/contact-center/cc-components/src/components/UserState/user-state.tsx +++ b/packages/contact-center/cc-components/src/components/UserState/user-state.tsx @@ -31,10 +31,10 @@ const UserStateComponent: React.FunctionComponent = (p logger, } = props; - const previousSelectableState = useMemo(() => getPreviousSelectableState(idleCodes), [idleCodes]); - const selectedKey = getSelectedKey(customState, currentState, idleCodes); - const items = buildDropdownItems(customState, idleCodes, currentState); - const sortedItems = sortDropdownItems(items); + const previousSelectableState = useMemo(() => getPreviousSelectableState(idleCodes, logger), [idleCodes, logger]); + const selectedKey = getSelectedKey(customState, currentState, idleCodes, logger); + const items = buildDropdownItems(customState, idleCodes, currentState, logger); + const sortedItems = sortDropdownItems(items, logger); return (
@@ -47,7 +47,7 @@ const UserStateComponent: React.FunctionComponent = (p showBorder selectedKey={selectedKey} items={sortedItems} - className={`state-select ${getDropdownClass(customState, currentState, idleCodes)}`} + className={`state-select ${getDropdownClass(customState, currentState, idleCodes, logger)}`} data-testid="state-select" > {(item) => { @@ -60,15 +60,19 @@ const UserStateComponent: React.FunctionComponent = (p
- + {item.name}
@@ -86,7 +90,7 @@ const UserStateComponent: React.FunctionComponent = (p triggerID="user-state-tooltip" > - {getTooltipText(customState, currentState, idleCodes)} + {getTooltipText(customState, currentState, idleCodes, logger)} diff --git a/packages/contact-center/cc-components/src/components/UserState/user-state.utils.ts b/packages/contact-center/cc-components/src/components/UserState/user-state.utils.ts index c0dfcf1bc..05622b29f 100644 --- a/packages/contact-center/cc-components/src/components/UserState/user-state.utils.ts +++ b/packages/contact-center/cc-components/src/components/UserState/user-state.utils.ts @@ -5,61 +5,101 @@ import {userStateLabels} from './constant'; /** * Gets the CSS class for the dropdown based on current state */ -export const getDropdownClass = (customState: ICustomState, currentState: string, idleCodes: IdleCode[]): string => { - if (customState) { - return 'custom'; // Custom state class - } - if (currentState === '0') { - return ''; - } - for (const item of idleCodes) { - if (item.id === currentState && item.name === ERROR_TRIGGERING_IDLE_CODES.RONA) { - return 'rona'; +export const getDropdownClass = ( + customState: ICustomState, + currentState: string, + idleCodes: IdleCode[], + logger? +): string => { + try { + if (customState) { + return 'custom'; // Custom state class + } + if (currentState === '0') { + return ''; } + for (const item of idleCodes) { + if (item.id === currentState && item.name === ERROR_TRIGGERING_IDLE_CODES.RONA) { + return 'rona'; + } + } + return 'idle'; + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in getDropdownClass', { + module: 'cc-components#user-state.utils.ts', + method: 'getDropdownClass', + error: error.message, + }); + return 'idle'; // Default fallback } - return 'idle'; }; /** * Gets the icon style configuration for a given item */ -export const getIconStyle = (item: { - id: string; - name: string; - developerName?: string; -}): {class: string; iconName: string} => { - if (item.developerName) { - return {class: 'custom', iconName: 'busy-presence-light'}; - } +export const getIconStyle = ( + item: { + id: string; + name: string; + developerName?: string; + }, + logger? +): {class: string; iconName: string} => { + try { + if (item.developerName) { + return {class: 'custom', iconName: 'busy-presence-light'}; + } - switch (item.name) { - case AgentUserState.Available: - return {class: 'available', iconName: 'active-presence-small-filled'}; - case ERROR_TRIGGERING_IDLE_CODES.RONA: - return {class: 'rona', iconName: 'dnd-presence-filled'}; - case ERROR_TRIGGERING_IDLE_CODES.INVALID_NUMBER: - case ERROR_TRIGGERING_IDLE_CODES.UNAVAILABLE: - case ERROR_TRIGGERING_IDLE_CODES.DECLINED: - case ERROR_TRIGGERING_IDLE_CODES.BUSY: - case ERROR_TRIGGERING_IDLE_CODES.CHANNEL_FAILURE: - return {class: 'idle', iconName: 'dnd-presence-filled'}; - default: - return {class: 'idle', iconName: 'recents-presence-filled'}; + switch (item.name) { + case AgentUserState.Available: + return {class: 'available', iconName: 'active-presence-small-filled'}; + case ERROR_TRIGGERING_IDLE_CODES.RONA: + return {class: 'rona', iconName: 'dnd-presence-filled'}; + case ERROR_TRIGGERING_IDLE_CODES.INVALID_NUMBER: + case ERROR_TRIGGERING_IDLE_CODES.UNAVAILABLE: + case ERROR_TRIGGERING_IDLE_CODES.DECLINED: + case ERROR_TRIGGERING_IDLE_CODES.BUSY: + case ERROR_TRIGGERING_IDLE_CODES.CHANNEL_FAILURE: + return {class: 'idle', iconName: 'dnd-presence-filled'}; + default: + return {class: 'idle', iconName: 'recents-presence-filled'}; + } + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in getIconStyle', { + module: 'cc-components#user-state.utils.ts', + method: 'getIconStyle', + error: error.message, + }); + return {class: 'idle', iconName: 'recents-presence-filled'}; // Default fallback } }; /** * Gets the tooltip text based on current state */ -export const getTooltipText = (customState: ICustomState, currentState: string, idleCodes: IdleCode[]): string => { - if (customState) { - const currentIdleCode = idleCodes.find((code) => code.id === currentState); - if (currentIdleCode?.name === AgentUserState.Available) { - return userStateLabels.customWithAvailableTooltip; +export const getTooltipText = ( + customState: ICustomState, + currentState: string, + idleCodes: IdleCode[], + logger? +): string => { + try { + if (customState) { + const currentIdleCode = idleCodes.find((code) => code.id === currentState); + if (currentIdleCode?.name === AgentUserState.Available) { + return userStateLabels.customWithAvailableTooltip; + } + return userStateLabels.customWithIdleStateTooltip.replace('{{currentState}}', currentIdleCode?.name || ''); } - return userStateLabels.customWithIdleStateTooltip.replace('{{currentState}}', currentIdleCode?.name || ''); + return userStateLabels.availableTooltip; + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in getTooltipText', { + module: 'cc-components#user-state.utils.ts', + method: 'getTooltipText', + error: error.message, + }); + return userStateLabels.availableTooltip; // Default fallback } - return userStateLabels.availableTooltip; }; /** @@ -71,68 +111,111 @@ export const handleSelectionChange = ( setAgentStatus: (auxCodeId: string) => void, logger ): void => { - // Remove 'hide-' prefix if present (for RONA states) - const cleanKey = key.startsWith('hide-') ? key.substring(5) : key; + try { + // Remove 'hide-' prefix if present (for RONA states) + const cleanKey = key.startsWith('hide-') ? key.substring(5) : key; - if (cleanKey !== currentState) { - logger?.info(`CC-Widgets: UserState: state changed to: ${cleanKey}`, { - module: 'cc-components#user-state.tsx', + if (cleanKey !== currentState) { + logger?.info(`CC-Widgets: UserState: state changed to: ${cleanKey}`, { + module: 'cc-components#user-state.tsx', + method: 'handleSelectionChange', + }); + setAgentStatus(cleanKey); + } + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in handleSelectionChange', { + module: 'cc-components#user-state.utils.ts', method: 'handleSelectionChange', + error: error.message, }); - setAgentStatus(cleanKey); } }; /** * Sorts dropdown items with Available first, then others */ -export const sortDropdownItems = (items: Array<{id: string; name: string}>): Array<{id: string; name: string}> => { - // Keep Available at the top, sort the rest alphabetically - return [ - ...items.filter((item) => item.name === AgentUserState.Available), - ...items.filter((item) => item.name !== AgentUserState.Available).sort((a, b) => a.name.localeCompare(b.name)), - ]; +export const sortDropdownItems = ( + items: Array<{id: string; name: string}>, + logger? +): Array<{id: string; name: string}> => { + try { + // Keep Available at the top, sort the rest alphabetically + return [ + ...items.filter((item) => item.name === AgentUserState.Available), + ...items.filter((item) => item.name !== AgentUserState.Available).sort((a, b) => a.name.localeCompare(b.name)), + ]; + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in sortDropdownItems', { + module: 'cc-components#user-state.utils.ts', + method: 'sortDropdownItems', + error: error.message, + }); + return items || []; // Return original array or empty array as fallback + } }; /** * Gets the previous selectable state (first non-RONA/Engaged state) */ -export const getPreviousSelectableState = (idleCodes: IdleCode[]): string => { - const selectableState = idleCodes.find( - (code) => - ![...Object.values(ERROR_TRIGGERING_IDLE_CODES), AgentUserState.Engaged].includes(code.name as AgentUserState) - ); - return selectableState?.id || '0'; +export const getPreviousSelectableState = (idleCodes: IdleCode[], logger?): string => { + try { + const selectableState = idleCodes.find( + (code) => + ![...Object.values(ERROR_TRIGGERING_IDLE_CODES), AgentUserState.Engaged].includes(code.name as AgentUserState) + ); + return selectableState?.id || '0'; + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in getPreviousSelectableState', { + module: 'cc-components#user-state.utils.ts', + method: 'getPreviousSelectableState', + error: error.message, + }); + return '0'; // Default fallback + } }; /** * Gets the selected key for the dropdown */ -export const getSelectedKey = (customState: ICustomState, currentState: string, idleCodes: IdleCode[]): string => { - if (customState && 'developerName' in customState) { - return `hide-${customState.developerName}`; - } +export const getSelectedKey = ( + customState: ICustomState, + currentState: string, + idleCodes: IdleCode[], + logger? +): string => { + try { + if (customState && 'developerName' in customState) { + return `hide-${customState.developerName}`; + } - // Check if current state exists in idleCodes first - const currentIdleCode = idleCodes.find((code) => code.id === currentState); + // Check if current state exists in idleCodes first + const currentIdleCode = idleCodes.find((code) => code.id === currentState); - // If currentIdleCode is not found, return currentState as-is - if (!currentIdleCode) { - return currentState; - } + // If currentIdleCode is not found, return currentState as-is + if (!currentIdleCode) { + return currentState; + } - // Check if current state is an error-triggering idle code (like RONA) - if (Object.values(ERROR_TRIGGERING_IDLE_CODES).includes(currentIdleCode.name)) { - return `hide-${currentState}`; - } + // Check if current state is an error-triggering idle code (like RONA) + if (Object.values(ERROR_TRIGGERING_IDLE_CODES).includes(currentIdleCode.name)) { + return `hide-${currentState}`; + } - // Check if current state is Engaged - const isEngaged = currentIdleCode.name === AgentUserState.Engaged; - if (isEngaged) { - return getPreviousSelectableState(idleCodes); - } + // Check if current state is Engaged + const isEngaged = currentIdleCode.name === AgentUserState.Engaged; + if (isEngaged) { + return getPreviousSelectableState(idleCodes, logger); + } - return currentState; + return currentState; + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in getSelectedKey', { + module: 'cc-components#user-state.utils.ts', + method: 'getSelectedKey', + error: error.message, + }); + return currentState; // Return original state as fallback + } }; /** @@ -141,43 +224,53 @@ export const getSelectedKey = (customState: ICustomState, currentState: string, export const buildDropdownItems = ( customState: ICustomState, idleCodes: IdleCode[], - currentState: string + currentState: string, + logger? ): Array<{id: string; name: string; developerName?: string}> => { - const items: Array<{id: string; name: string; developerName?: string}> = []; - - // Add custom state if present - if (customState && 'developerName' in customState) { - items.push({ - name: customState.name, - id: `hide-${customState.developerName}`, - developerName: customState.developerName, - }); - } + try { + const items: Array<{id: string; name: string; developerName?: string}> = []; - // Add regular idle codes, but handle RONA specially - for (const code of idleCodes) { - // Skip Engaged states entirely - if (code.name === AgentUserState.Engaged) { - continue; + // Add custom state if present + if (customState && 'developerName' in customState) { + items.push({ + name: customState.name, + id: `hide-${customState.developerName}`, + developerName: customState.developerName, + }); } - // For RONA: only include if it's the current state - if (Object.values(ERROR_TRIGGERING_IDLE_CODES).includes(code.name)) { - if (code.id === currentState) { - items.push({ - ...code, - id: `hide-${code.id}`, // Use hide- prefix for RONA - }); + // Add regular idle codes, but handle RONA specially + for (const code of idleCodes) { + // Skip Engaged states entirely + if (code.name === AgentUserState.Engaged) { + continue; + } + + // For RONA: only include if it's the current state + if (Object.values(ERROR_TRIGGERING_IDLE_CODES).includes(code.name)) { + if (code.id === currentState) { + items.push({ + ...code, + id: `hide-${code.id}`, // Use hide- prefix for RONA + }); + } + continue; } - continue; + + // Add all other states normally + items.push({ + id: code.id, + name: code.name, + }); } - // Add all other states normally - items.push({ - id: code.id, - name: code.name, + return items; + } catch (error) { + logger?.error('CC-Widgets: UserState: Error in buildDropdownItems', { + module: 'cc-components#user-state.utils.ts', + method: 'buildDropdownItems', + error: error.message, }); + return []; // Return empty array as fallback } - - return items; }; diff --git a/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx b/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx index dc37e5405..8268714d2 100644 --- a/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx +++ b/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx @@ -9,8 +9,9 @@ const AutoWrapupTimer: React.FC = ({ secondsUntilAutoWrapup, allowCancelAutoWrapup, handleCancelWrapup, + logger, }) => { - const {containerClassName, iconClassName, iconName, formattedTime} = getTimerUIState(secondsUntilAutoWrapup); + const {containerClassName, iconClassName, iconName, formattedTime} = getTimerUIState(secondsUntilAutoWrapup, logger); return ( <> diff --git a/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.utils.ts b/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.utils.ts index dbef2f4a2..8e7a45ed2 100644 --- a/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/AutoWrapupTimer/AutoWrapupTimer.utils.ts @@ -3,53 +3,114 @@ import {TimerUIState} from '../task.types'; /** * Determines if the timer is in urgent state (10 seconds or less) */ -export const isTimerUrgent = (secondsUntilAutoWrapup: number): boolean => { - return secondsUntilAutoWrapup <= 10; +export const isTimerUrgent = (secondsUntilAutoWrapup: number, logger?): boolean => { + try { + return secondsUntilAutoWrapup <= 10; + } catch (error) { + logger?.error('CC-Widgets: AutoWrapupTimer: Error in isTimerUrgent', { + module: 'cc-components#AutoWrapupTimer.utils.ts', + method: 'isTimerUrgent', + error: error.message, + }); + return false; // Default safe fallback + } }; /** * Gets the appropriate container CSS class based on urgency */ -export const getContainerClassName = (isUrgent: boolean): string => { - return isUrgent ? 'wrapup-timer-container urgent' : 'wrapup-timer-container'; +export const getContainerClassName = (isUrgent: boolean, logger?): string => { + try { + return isUrgent ? 'wrapup-timer-container urgent' : 'wrapup-timer-container'; + } catch (error) { + logger?.error('CC-Widgets: AutoWrapupTimer: Error in getContainerClassName', { + module: 'cc-components#AutoWrapupTimer.utils.ts', + method: 'getContainerClassName', + error: error.message, + }); + return 'wrapup-timer-container'; // Default safe fallback + } }; /** * Gets the appropriate icon CSS class based on urgency */ -export const getIconClassName = (isUrgent: boolean): string => { - return isUrgent ? 'wrapup-timer-icon urgent' : 'wrapup-timer-icon'; +export const getIconClassName = (isUrgent: boolean, logger?): string => { + try { + return isUrgent ? 'wrapup-timer-icon urgent' : 'wrapup-timer-icon'; + } catch (error) { + logger?.error('CC-Widgets: AutoWrapupTimer: Error in getIconClassName', { + module: 'cc-components#AutoWrapupTimer.utils.ts', + method: 'getIconClassName', + error: error.message, + }); + return 'wrapup-timer-icon'; // Default safe fallback + } }; /** * Gets the appropriate icon name based on urgency */ -export const getIconName = (isUrgent: boolean): string => { - return isUrgent ? 'alert-active-bold' : 'recents-bold'; +export const getIconName = (isUrgent: boolean, logger?): string => { + try { + return isUrgent ? 'alert-active-bold' : 'recents-bold'; + } catch (error) { + logger?.error('CC-Widgets: AutoWrapupTimer: Error in getIconName', { + module: 'cc-components#AutoWrapupTimer.utils.ts', + method: 'getIconName', + error: error.message, + }); + return 'recents-bold'; // Default safe fallback + } }; /** * Formats seconds into MM:SS format */ -export const formatTimerDisplay = (secondsUntilAutoWrapup: number): string => { - const minutes = Math.floor(secondsUntilAutoWrapup / 60); - const seconds = secondsUntilAutoWrapup % 60; +export const formatTimerDisplay = (secondsUntilAutoWrapup: number, logger?): string => { + try { + const minutes = Math.floor(secondsUntilAutoWrapup / 60); + const seconds = secondsUntilAutoWrapup % 60; - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } catch (error) { + logger?.error('CC-Widgets: AutoWrapupTimer: Error in formatTimerDisplay', { + module: 'cc-components#AutoWrapupTimer.utils.ts', + method: 'formatTimerDisplay', + error: error.message, + }); + return '00:00'; // Default safe fallback + } }; /** * Gets all timer-related UI state in one object */ -export const getTimerUIState = (secondsUntilAutoWrapup: number): TimerUIState => { - const isUrgent = isTimerUrgent(secondsUntilAutoWrapup); +export const getTimerUIState = (secondsUntilAutoWrapup: number, logger?): TimerUIState => { + try { + const isUrgent = isTimerUrgent(secondsUntilAutoWrapup, logger); - return { - isUrgent, - containerClassName: getContainerClassName(isUrgent), - iconClassName: getIconClassName(isUrgent), - iconName: getIconName(isUrgent), - formattedTime: formatTimerDisplay(secondsUntilAutoWrapup), - }; + return { + isUrgent, + containerClassName: getContainerClassName(isUrgent, logger), + iconClassName: getIconClassName(isUrgent, logger), + iconName: getIconName(isUrgent, logger), + formattedTime: formatTimerDisplay(secondsUntilAutoWrapup, logger), + }; + } catch (error) { + logger?.error('CC-Widgets: AutoWrapupTimer: Error in getTimerUIState', { + module: 'cc-components#AutoWrapupTimer.utils.ts', + method: 'getTimerUIState', + error: error.message, + }); + // Return safe default state + return { + isUrgent: false, + containerClassName: 'wrapup-timer-container', + iconClassName: 'wrapup-timer-icon', + iconName: 'recents-bold', + formattedTime: '00:00', + }; + } }; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts index 5486b2215..06d48003d 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts @@ -34,55 +34,86 @@ export const createConsultButtons = ( muteUnmute: boolean, onTransfer?: () => void, handleConsultMuteToggle?: () => void, - handleEndConsult?: () => void + handleEndConsult?: () => void, + logger? ): ButtonConfig[] => { - return [ - { - key: 'mute', - icon: isMuted ? 'microphone-muted-bold' : 'microphone-bold', - onClick: handleConsultMuteToggle || (() => {}), - tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, - className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, - disabled: isMuteDisabled, - shouldShow: muteUnmute, - }, - { - key: 'transfer', - icon: 'next-bold', - tooltip: 'Transfer Consult', - onClick: onTransfer || (() => {}), - className: 'call-control-button', - disabled: !consultCompleted, - shouldShow: isAgentBeingConsulted && !!onTransfer, - }, - { - key: 'cancel', - icon: 'headset-muted-bold', - tooltip: 'End Consult', - onClick: handleEndConsult || (() => {}), - className: 'call-control-consult-button-cancel', - shouldShow: isEndConsultEnabled || isAgentBeingConsulted, - }, - ]; + try { + return [ + { + key: 'mute', + icon: isMuted ? 'microphone-muted-bold' : 'microphone-bold', + onClick: handleConsultMuteToggle || (() => {}), + tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, + className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, + disabled: isMuteDisabled, + shouldShow: muteUnmute, + }, + { + key: 'transfer', + icon: 'next-bold', + tooltip: 'Transfer Consult', + onClick: onTransfer || (() => {}), + className: 'call-control-button', + disabled: !consultCompleted, + shouldShow: isAgentBeingConsulted && !!onTransfer, + }, + { + key: 'cancel', + icon: 'headset-muted-bold', + tooltip: 'End Consult', + onClick: handleEndConsult || (() => {}), + className: 'call-control-consult-button-cancel', + shouldShow: isEndConsultEnabled || isAgentBeingConsulted, + }, + ]; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in createConsultButtons', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'createConsultButtons', + error: error.message, + }); + // Return empty safe fallback + return []; + } }; /** * Filters buttons that should be visible */ -export const getVisibleButtons = (buttons: ButtonConfig[]): ButtonConfig[] => { - return buttons.filter((button) => button.shouldShow); +export const getVisibleButtons = (buttons: ButtonConfig[], logger?): ButtonConfig[] => { + try { + return buttons.filter((button) => button.shouldShow); + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in getVisibleButtons', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'getVisibleButtons', + error: error.message, + }); + // Return empty safe fallback + return []; + } }; /** * Creates initials from a name string */ -export const createInitials = (name: string): string => { - return name - .split(' ') - .map((word) => word[0]) - .join('') - .slice(0, 2) - .toUpperCase(); +export const createInitials = (name: string, logger?): string => { + try { + return name + .split(' ') + .map((word) => word[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in createInitials', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'createInitials', + error: error.message, + }); + // Return safe default + return '??'; + } }; /** @@ -159,8 +190,18 @@ export const handleMuteToggle = ( /** * Gets the consult status text based on completion state */ -export const getConsultStatusText = (consultCompleted: boolean): string => { - return consultCompleted ? 'Consulting' : 'Consult requested'; +export const getConsultStatusText = (consultCompleted: boolean, logger?): string => { + try { + return consultCompleted ? 'Consulting' : 'Consult requested'; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in getConsultStatusText', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'getConsultStatusText', + error: error.message, + }); + // Return safe default + return 'Consulting'; + } }; /** @@ -177,35 +218,72 @@ export const handleListItemPress = (title: string, onButtonPress: () => void, lo /** * Determines if tabs should be shown based on available data */ -export const shouldShowTabs = (buddyAgents: BuddyDetails[], queues: ContactServiceQueue[]): boolean => { - const noAgents = !buddyAgents || buddyAgents.length === 0; - const noQueues = !queues || queues.length === 0; - return !(noAgents && noQueues); +export const shouldShowTabs = (buddyAgents: BuddyDetails[], queues: ContactServiceQueue[], logger?): boolean => { + try { + const noAgents = !buddyAgents || buddyAgents.length === 0; + const noQueues = !queues || queues.length === 0; + return !(noAgents && noQueues); + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in shouldShowTabs', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'shouldShowTabs', + error: error.message, + }); + // Return safe default + return false; + } }; /** * Checks if agents list is empty */ -export const isAgentsEmpty = (buddyAgents: BuddyDetails[]): boolean => { - return !buddyAgents || buddyAgents.length === 0; +export const isAgentsEmpty = (buddyAgents: BuddyDetails[], logger?): boolean => { + try { + return !buddyAgents || buddyAgents.length === 0; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in isAgentsEmpty', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'isAgentsEmpty', + error: error.message, + }); + // Return safe default + return true; + } }; /** * Checks if queues list is empty */ -export const isQueuesEmpty = (queues: ContactServiceQueue[]): boolean => { - return !queues || queues.length === 0; +export const isQueuesEmpty = (queues: ContactServiceQueue[], logger?): boolean => { + try { + return !queues || queues.length === 0; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in isQueuesEmpty', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'isQueuesEmpty', + error: error.message, + }); + // Return safe default + return true; + } }; /** * Handles tab selection with logging */ export const handleTabSelection = (key: string, setSelectedTab: (tab: string) => void, logger: ILogger): void => { - setSelectedTab(key); - logger.log(`CC-Widgets: ConsultTransferPopover: tab selected: ${key}`, { - module: 'consult-transfer-popover.tsx', - method: 'onTabSelection', - }); + try { + setSelectedTab(key); + logger.log(`CC-Widgets: ConsultTransferPopover: tab selected: ${key}`, { + module: 'consult-transfer-popover.tsx', + method: 'onTabSelection', + }); + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handleTabSelection: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handleTabSelection', + }); + } }; /** @@ -217,12 +295,19 @@ export const handleAgentSelection = ( onAgentSelect: ((agentId: string, agentName: string) => void) | undefined, logger: ILogger ): void => { - logger.info(`CC-Widgets: ConsultTransferPopover: agent selected: ${agentId}`, { - module: 'consult-transfer-popover.tsx', - method: 'onAgentSelect', - }); - if (onAgentSelect) { - onAgentSelect(agentId, agentName); + try { + logger.info(`CC-Widgets: ConsultTransferPopover: agent selected: ${agentId}`, { + module: 'consult-transfer-popover.tsx', + method: 'onAgentSelect', + }); + if (onAgentSelect) { + onAgentSelect(agentId, agentName); + } + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handleAgentSelection: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handleAgentSelection', + }); } }; @@ -235,89 +320,153 @@ export const handleQueueSelection = ( onQueueSelect: ((queueId: string, queueName: string) => void) | undefined, logger: ILogger ): void => { - logger.log(`CC-Widgets: ConsultTransferPopover: queue selected: ${queueId}`, { - module: 'consult-transfer-popover.tsx', - method: 'onQueueSelect', - }); - if (onQueueSelect) { - onQueueSelect(queueId, queueName); + try { + logger.log(`CC-Widgets: ConsultTransferPopover: queue selected: ${queueId}`, { + module: 'consult-transfer-popover.tsx', + method: 'onQueueSelect', + }); + if (onQueueSelect) { + onQueueSelect(queueId, queueName); + } + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handleQueueSelection: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handleQueueSelection', + }); } }; /** * Gets the appropriate empty state message based on context */ -export const getEmptyStateMessage = (selectedTab: string, showTabs: boolean): string => { - if (!showTabs) { - return "We can't find any queue or agent available for now."; - } +export const getEmptyStateMessage = (selectedTab: string, showTabs: boolean, logger?): string => { + try { + if (!showTabs) { + return "We can't find any queue or agent available for now."; + } - if (selectedTab === 'Agents') { - return "We can't find any agent available for now."; - } + if (selectedTab === 'Agents') { + return "We can't find any agent available for now."; + } - return "We can't find any queue available for now."; + return "We can't find any queue available for now."; + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in getEmptyStateMessage: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'getEmptyStateMessage', + }); + // Return safe default + return "We can't find any queue or agent available for now."; + } }; /** * Creates list item data from buddy agents */ -export const createAgentListData = (buddyAgents: BuddyDetails[]): ListItemData[] => { - return buddyAgents.map((agent) => ({ - id: agent.agentId, - name: agent.agentName, - })); +export const createAgentListData = (buddyAgents: BuddyDetails[], logger?): ListItemData[] => { + try { + return buddyAgents.map((agent) => ({ + id: agent.agentId, + name: agent.agentName, + })); + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in createAgentListData: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'createAgentListData', + }); + // Return empty safe fallback + return []; + } }; /** * Creates list item data from queues */ -export const createQueueListData = (queues: ContactServiceQueue[]): ListItemData[] => { - return queues.map((queue) => ({ - id: queue.id, - name: queue.name, - })); +export const createQueueListData = (queues: ContactServiceQueue[], logger?): ListItemData[] => { + try { + return queues.map((queue) => ({ + id: queue.id, + name: queue.name, + })); + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in createQueueListData: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'createQueueListData', + }); + // Return empty safe fallback + return []; + } }; /** * Creates a timer key based on timestamp */ -export const createTimerKey = (startTimeStamp: number): string => { - return `timer-${startTimeStamp}`; +export const createTimerKey = (startTimeStamp: number, logger?): string => { + try { + return `timer-${startTimeStamp}`; + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in createTimerKey: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'createTimerKey', + }); + // Return safe default + return 'timer-0'; + } }; /** * Handles popover open with logging */ export const handlePopoverOpen = (menuType: string, setActiveMenu: (menu: string) => void, logger: ILogger): void => { - logger.info(`CC-Widgets: CallControl: opening ${menuType} popover`, { - module: 'call-control.tsx', - method: 'handlePopoverOpen', - }); - setActiveMenu(menuType); + try { + logger.info(`CC-Widgets: CallControl: opening ${menuType} popover`, { + module: 'call-control.tsx', + method: 'handlePopoverOpen', + }); + setActiveMenu(menuType); + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handlePopoverOpen: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handlePopoverOpen', + }); + } }; /** * Handles popover close with logging */ export const handlePopoverClose = (setActiveMenu: (menu: string | null) => void, logger: ILogger): void => { - logger.info('CC-Widgets: CallControl: closing popover', { - module: 'call-control.tsx', - method: 'handlePopoverClose', - }); - setActiveMenu(null); + try { + logger.info('CC-Widgets: CallControl: closing popover', { + module: 'call-control.tsx', + method: 'handlePopoverClose', + }); + setActiveMenu(null); + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handlePopoverClose: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handlePopoverClose', + }); + } }; /** * Handles hold toggle with logging */ export const handleHoldToggle = (toggleHold: (() => void) | undefined, logger: ILogger): void => { - logger.info('CC-Widgets: CallControl: hold toggle clicked', { - module: 'call-control.tsx', - method: 'handleHoldToggle', - }); - if (toggleHold) { - toggleHold(); + try { + logger.info('CC-Widgets: CallControl: hold toggle clicked', { + module: 'call-control.tsx', + method: 'handleHoldToggle', + }); + if (toggleHold) { + toggleHold(); + } + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handleHoldToggle: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handleHoldToggle', + }); } }; @@ -325,21 +474,38 @@ export const handleHoldToggle = (toggleHold: (() => void) | undefined, logger: I * Handles wrapup call with logging */ export const handleWrapupCall = (onWrapupCall: (() => void) | undefined, logger: ILogger): void => { - logger.info('CC-Widgets: CallControl: wrapup call clicked', { - module: 'call-control.tsx', - method: 'handleWrapupCall', - }); - if (onWrapupCall) { - onWrapupCall(); + try { + logger.info('CC-Widgets: CallControl: wrapup call clicked', { + module: 'call-control.tsx', + method: 'handleWrapupCall', + }); + if (onWrapupCall) { + onWrapupCall(); + } + } catch (error) { + logger.error(`CC-Widgets: CallControlCustom: Error in handleWrapupCall: ${error.message}`, { + module: 'cc-components#call-control-custom.utils.ts', + method: 'handleWrapupCall', + }); } }; /** * Validates if a menu type is supported */ -export const isValidMenuType = (menuType: string): boolean => { - const validMenuTypes = ['Consult', 'Transfer']; - return validMenuTypes.includes(menuType); +export const isValidMenuType = (menuType: string, logger?): boolean => { + try { + const validMenuTypes = ['Consult', 'Transfer']; + return validMenuTypes.includes(menuType); + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in isValidMenuType', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'isValidMenuType', + error: error.message, + }); + // Return safe default + return false; + } }; /** @@ -348,58 +514,119 @@ export const isValidMenuType = (menuType: string): boolean => { export const getButtonStyleClass = ( isActive: boolean, isDisabled: boolean, - baseClass = 'call-control-button' + baseClass = 'call-control-button', + logger? ): string => { - if (isDisabled) { - return `${baseClass}-disabled`; - } - if (isActive) { - return `${baseClass}-active`; + try { + if (isDisabled) { + return `${baseClass}-disabled`; + } + if (isActive) { + return `${baseClass}-active`; + } + return baseClass; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in getButtonStyleClass', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'getButtonStyleClass', + error: error.message, + }); + // Return safe default + return 'call-control-button'; } - return baseClass; }; /** * Formats elapsed time for display */ -export const formatElapsedTime = (startTime: number): string => { - const elapsed = Date.now() - startTime; - const seconds = Math.floor(elapsed / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); +export const formatElapsedTime = (startTime: number, logger?): string => { + try { + const elapsed = Date.now() - startTime; + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); - if (hours > 0) { - return `${hours}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`; + if (hours > 0) { + return `${hours}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`; + } + return `${minutes}:${(seconds % 60).toString().padStart(2, '0')}`; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in formatElapsedTime', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'formatElapsedTime', + error: error.message, + }); + // Return safe default + return '0:00'; } - return `${minutes}:${(seconds % 60).toString().padStart(2, '0')}`; }; /** * Checks if an agent is available for selection */ -export const isAgentAvailable = (agent: BuddyDetails): boolean => { - return agent && agent.agentId && agent.agentName && agent.agentName.trim().length > 0; +export const isAgentAvailable = (agent: BuddyDetails, logger?): boolean => { + try { + return agent && agent.agentId && agent.agentName && agent.agentName.trim().length > 0; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in isAgentAvailable', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'isAgentAvailable', + error: error.message, + }); + // Return safe default + return false; + } }; /** * Checks if a queue is available for selection */ -export const isQueueAvailable = (queue: ContactServiceQueue): boolean => { - return queue && queue.id && queue.name && queue.name.trim().length > 0; +export const isQueueAvailable = (queue: ContactServiceQueue, logger?): boolean => { + try { + return queue && queue.id && queue.name && queue.name.trim().length > 0; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in isQueueAvailable', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'isQueueAvailable', + error: error.message, + }); + // Return safe default + return false; + } }; /** * Filters available agents */ -export const filterAvailableAgents = (agents: BuddyDetails[]): BuddyDetails[] => { - return agents ? agents.filter(isAgentAvailable) : []; +export const filterAvailableAgents = (agents: BuddyDetails[], logger?): BuddyDetails[] => { + try { + return agents ? agents.filter((agent) => isAgentAvailable(agent, logger)) : []; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in filterAvailableAgents', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'filterAvailableAgents', + error: error.message, + }); + // Return empty safe fallback + return []; + } }; /** * Filters available queues */ -export const filterAvailableQueues = (queues: ContactServiceQueue[]): ContactServiceQueue[] => { - return queues ? queues.filter(isQueueAvailable) : []; +export const filterAvailableQueues = (queues: ContactServiceQueue[], logger?): ContactServiceQueue[] => { + try { + return queues ? queues.filter((queue) => isQueueAvailable(queue, logger)) : []; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in filterAvailableQueues', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'filterAvailableQueues', + error: error.message, + }); + // Return empty safe fallback + return []; + } }; /** @@ -407,11 +634,24 @@ export const filterAvailableQueues = (queues: ContactServiceQueue[]): ContactSer */ export const debounce = unknown>( func: T, - wait: number + wait: number, + logger? ): ((...args: Parameters) => void) => { - let timeout: NodeJS.Timeout; - return (...args: Parameters) => { - clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; + try { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + } catch (error) { + logger?.error('CC-Widgets: CallControlCustom: Error in debounce', { + module: 'cc-components#call-control-custom.utils.ts', + method: 'debounce', + error: error.message, + }); + // Return safe fallback function + return (...args: Parameters) => { + func(...args); + }; + } }; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index ac09c248a..3967d610d 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -66,8 +66,8 @@ function CallControlComponent(props: CallControlComponentProps) { } = props; useEffect(() => { - updateCallStateFromTask(currentTask, setIsHeld, setIsRecording); - }, [currentTask]); + updateCallStateFromTask(currentTask, setIsHeld, setIsRecording, logger); + }, [currentTask, logger]); const handletoggleHold = () => { handleToggleHoldUtil(isHeld, toggleHold, setIsHeld, logger); @@ -89,7 +89,7 @@ function CallControlComponent(props: CallControlComponentProps) { }; const handleWrapupChange = (text, value) => { - handleWrapupChangeUtil(text, value, setSelectedWrapupReason, setSelectedWrapupId); + handleWrapupChangeUtil(text, value, setSelectedWrapupReason, setSelectedWrapupId, logger); }; const handleTargetSelect = (id: string, name: string, type: DestinationType) => { @@ -109,11 +109,12 @@ function CallControlComponent(props: CallControlComponentProps) { const currentMediaType = getMediaType( currentTask.data.interaction.mediaType as MediaChannelType, - currentTask.data.interaction.mediaChannel as MediaChannelType + currentTask.data.interaction.mediaChannel as MediaChannelType, + logger ); const mediaType = currentTask.data.interaction.mediaType as MediaChannelType; - const isTelephony = isTelephonyMediaType(mediaType); + const isTelephony = isTelephonyMediaType(mediaType, logger); const buttons = buildCallControlButtons( isMuted, @@ -125,16 +126,21 @@ function CallControlComponent(props: CallControlComponentProps) { handleMuteToggle, handletoggleHold, toggleRecording, - endCall + endCall, + logger ); - const filteredButtons = filterButtonsForConsultation(buttons, consultInitiated, isTelephony); + const filteredButtons = filterButtonsForConsultation(buttons, consultInitiated, isTelephony, logger); if (!currentTask) return null; return ( <> - +
{!(consultAccepted && isTelephony) && !controlVisibility.wrapup && (
@@ -171,7 +177,7 @@ function CallControlComponent(props: CallControlComponentProps) { closeButtonPlacement="top-right" closeButtonProps={{ 'aria-label': 'Close popover', - onPress: () => handleCloseButtonPress(setShowAgentMenu, setAgentMenuType), + onPress: () => handleCloseButtonPress(setShowAgentMenu, setAgentMenuType, logger), outline: true, }} triggerComponent={ @@ -293,7 +299,9 @@ function CallControlComponent(props: CallControlComponentProps) { className="wrapup-select" data-testid="call-control:wrapup-select" placeholder={SELECT} - onChange={(event: CustomEvent) => handleWrapupReasonChange(event, wrapupCodes, handleWrapupChange)} + onChange={(event: CustomEvent) => + handleWrapupReasonChange(event, wrapupCodes, handleWrapupChange, logger) + } > {wrapupCodes?.map((code) => (
@@ -547,7 +547,7 @@ exports[`Station Login Component Actions ProfileMode interaction confirmation Po
@@ -857,7 +857,7 @@ exports[`Station Login Component Actions SignOut Modal Popup should close modal
@@ -1177,7 +1177,7 @@ exports[`Station Login Component Actions SignOut Modal Popup show signOut modal @@ -1486,7 +1486,7 @@ exports[`Station Login Component Actions SignOut Modal Popup show signOut modal @@ -1795,7 +1795,7 @@ exports[`Station Login Component Actions SignOut Modal Popup show signOut modal @@ -2104,7 +2104,7 @@ exports[`Station Login Component Actions SignOut Modal Popup show signOut modal @@ -2413,7 +2413,7 @@ exports[`Station Login Component Actions calls handleDNInputChanged with correct @@ -2733,7 +2733,7 @@ exports[`Station Login Component Actions calls handleTeamSelectChanged utility f @@ -3032,7 +3032,7 @@ exports[`Station Login Component Rendering does not render loginOptions if not i @@ -3352,7 +3352,7 @@ exports[`Station Login Component Rendering renders login failure when passed 1`] @@ -3680,7 +3680,7 @@ exports[`Station Login Component Rendering renders save error when passed 1`] = @@ -4008,7 +4008,7 @@ exports[`Station Login Component Rendering renders the component correctly 1`] = @@ -4321,7 +4321,7 @@ exports[`Station Login Component Rendering renders the component correctly in pr diff --git a/packages/contact-center/cc-components/tests/components/StationLogin/station-login.tsx b/packages/contact-center/cc-components/tests/components/StationLogin/station-login.tsx index e99137e75..d4061e2fe 100644 --- a/packages/contact-center/cc-components/tests/components/StationLogin/station-login.tsx +++ b/packages/contact-center/cc-components/tests/components/StationLogin/station-login.tsx @@ -106,6 +106,13 @@ describe('Station Login Component', () => { saveConfirmDialogRef: {current: null}, })); const mockUpdateDialNumberLabel = jest.fn(); + const mockHandleCCSignoutKeyDown = jest.fn((event, setShowCCSignOutModal) => { + if (event.key === 'Escape') { + setShowCCSignOutModal(false); + } + }); + const mockValidateDialNumber = jest.fn(); + const mockHandleOnCCSignOut = jest.fn(); beforeEach(() => { // Mock all utility functions @@ -119,6 +126,9 @@ describe('Station Login Component', () => { jest.spyOn(stationLoginUtils, 'saveConfirmCancelClicked').mockImplementation(mockSaveConfirmCancelClicked); jest.spyOn(stationLoginUtils, 'createStationLoginRefs').mockImplementation(mockCreateStationLoginRefs); jest.spyOn(stationLoginUtils, 'updateDialNumberLabel').mockImplementation(mockUpdateDialNumberLabel); + jest.spyOn(stationLoginUtils, 'handleCCSignoutKeyDown').mockImplementation(mockHandleCCSignoutKeyDown); + jest.spyOn(stationLoginUtils, 'validateDialNumber').mockImplementation(mockValidateDialNumber); + jest.spyOn(stationLoginUtils, 'handleOnCCSignOut').mockImplementation(mockHandleOnCCSignOut); }); afterEach(() => { @@ -562,7 +572,12 @@ describe('Station Login Component', () => { expect(confirmSignOutButton).toHaveTextContent(StationLoginLabels.SIGN_OUT); fireEvent.click(confirmSignOutButton); - expect(mockContinueClicked).toHaveBeenCalledWith(mockSignOutModalRef, mockSignOut, expect.any(Function)); + expect(mockContinueClicked).toHaveBeenCalledWith( + mockSignOutModalRef, + mockSignOut, + expect.any(Function), + loggerMock + ); }); it('show signOut modal when onCCSignOut is present and sign out is clicked', async () => { @@ -617,13 +632,18 @@ describe('Station Login Component', () => { const continueBtn = screen.getByTestId('ContinueButton'); fireEvent.click(continueBtn); - expect(stationLoginUtils.continueClicked).toHaveBeenCalledWith(mockModal, handleContinue, expect.any(Function)); + expect(stationLoginUtils.continueClicked).toHaveBeenCalledWith( + mockModal, + handleContinue, + expect.any(Function), + loggerMock + ); }); it('should close sign-out modal and update state on cancel button click', async () => { const mockSetShowCCSignOutModal = jest.fn(); const mockEvent = {key: 'Escape'} as React.KeyboardEvent; - stationLoginUtils.handleCCSignoutKeyDown(mockEvent, mockSetShowCCSignOutModal); + stationLoginUtils.handleCCSignoutKeyDown(mockEvent, mockSetShowCCSignOutModal, loggerMock); expect(mockSetShowCCSignOutModal).toHaveBeenCalledWith(false); const screen = await render(); const cancelButton = screen.getByTestId('cc-cancel-button'); @@ -652,7 +672,7 @@ describe('Station Login Component', () => { const cancelButton = confirmationPopup.querySelectorAll('mdc-button')[0]; fireEvent.click(cancelButton); - expect(mockSaveConfirmCancelClicked).toHaveBeenCalledWith(mockRef, expect.any(Function)); + expect(mockSaveConfirmCancelClicked).toHaveBeenCalledWith(mockRef, expect.any(Function), loggerMock); }); it('it should call handleConfirmCancelClicked when clicked on cancel in popup', async () => { @@ -673,7 +693,7 @@ describe('Station Login Component', () => { const cancelButton = confirmationPopup.querySelectorAll('mdc-button')[1]; fireEvent.click(cancelButton); - expect(mockSaveConfirmCancelClicked).toHaveBeenCalledWith(mockRef, expect.any(Function)); + expect(mockSaveConfirmCancelClicked).toHaveBeenCalledWith(mockRef, expect.any(Function), loggerMock); }); it('it should call handleSaveConfirm when clicked on confirm in popup', async () => { @@ -694,7 +714,12 @@ describe('Station Login Component', () => { const confirmButton = confirmationPopup.querySelectorAll('mdc-button')[2]; fireEvent.click(confirmButton); - expect(mockHandleSaveConfirm).toHaveBeenCalledWith(mockRef, expect.any(Function), props.saveLoginOptions); + expect(mockHandleSaveConfirm).toHaveBeenCalledWith( + mockRef, + expect.any(Function), + props.saveLoginOptions, + loggerMock + ); }); }); describe('Save Login Options', () => { @@ -722,4 +747,85 @@ describe('Station Login Component', () => { }); }); }); + + describe('Error Handling ', () => { + // Clear all mocks before these tests to call actual implementations + beforeEach(() => { + jest.restoreAllMocks(); + }); + + // Restore mocks after these tests + afterEach(() => { + jest.spyOn(stationLoginUtils, 'handleLoginOptionChanged').mockImplementation(mockHandleLoginOptionChanged); + jest.spyOn(stationLoginUtils, 'handleDNInputChanged').mockImplementation(mockHandleDNInputChanged); + jest.spyOn(stationLoginUtils, 'handleTeamSelectChanged').mockImplementation(mockHandleTeamSelectChanged); + jest.spyOn(stationLoginUtils, 'handleModals').mockImplementation(mockHandleModals); + jest.spyOn(stationLoginUtils, 'continueClicked').mockImplementation(mockContinueClicked); + jest.spyOn(stationLoginUtils, 'ccCancelButtonClicked').mockImplementation(mockCcCancelButtonClicked); + jest.spyOn(stationLoginUtils, 'handleSaveConfirm').mockImplementation(mockHandleSaveConfirm); + jest.spyOn(stationLoginUtils, 'saveConfirmCancelClicked').mockImplementation(mockSaveConfirmCancelClicked); + jest.spyOn(stationLoginUtils, 'createStationLoginRefs').mockImplementation(mockCreateStationLoginRefs); + jest.spyOn(stationLoginUtils, 'updateDialNumberLabel').mockImplementation(mockUpdateDialNumberLabel); + jest.spyOn(stationLoginUtils, 'handleCCSignoutKeyDown').mockImplementation(mockHandleCCSignoutKeyDown); + jest.spyOn(stationLoginUtils, 'validateDialNumber').mockImplementation(mockValidateDialNumber); + jest.spyOn(stationLoginUtils, 'handleOnCCSignOut').mockImplementation(mockHandleOnCCSignOut); + }); + + it('should log errors when handleModals throws an exception', () => { + const mockError = new Error('Test error in handleModals'); + const mockRef = { + current: { + showModal: jest.fn(() => { + throw mockError; + }), + }, + }; + + stationLoginUtils.handleModals(mockRef, null, null, true, false, false, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in handleModals', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleModals', + error: 'Test error in handleModals', + }); + }); + + it('should log errors when continueClicked throws an exception', () => { + const mockError = new Error('Test error in continueClicked'); + const mockCallback = jest.fn(() => { + throw mockError; + }); + const mockRef = {current: {close: jest.fn()}}; + const mockSetShowModal = jest.fn(); + + stationLoginUtils.continueClicked(mockRef, mockCallback, mockSetShowModal, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in continueClicked', { + module: 'cc-components#station-login.utils.tsx', + method: 'continueClicked', + error: 'Test error in continueClicked', + }); + }); + + it('should log errors when validateDialNumber throws an exception', () => { + // Mock RegExp constructor to throw an error + const originalRegExp = global.RegExp; + global.RegExp = jest.fn(() => { + throw new Error('Test error in validateDialNumber'); + }) as unknown as typeof RegExp; + + const mockSetError = jest.fn(); + const result = stationLoginUtils.validateDialNumber('invalid', null, mockSetError, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in validateDialNumber', { + module: 'cc-components#station-login.utils.tsx', + method: 'validateDialNumber', + error: 'Test error in validateDialNumber', + }); + expect(result).toBe(true); // Should return true (error state) when exception occurs + + // Restore original RegExp + global.RegExp = originalRegExp; + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/StationLogin/station-login.utils.tsx b/packages/contact-center/cc-components/tests/components/StationLogin/station-login.utils.tsx index 53c546a1a..c61bd416a 100644 --- a/packages/contact-center/cc-components/tests/components/StationLogin/station-login.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/StationLogin/station-login.utils.tsx @@ -34,14 +34,14 @@ describe('Station Login Utils', () => { it('should set showCCSignOutModal to false when Escape key is pressed', () => { const mockSetShowCCSignOutModal = jest.fn(); const mockEvent = {key: 'Escape'} as React.KeyboardEvent; - handleCCSignoutKeyDown(mockEvent, mockSetShowCCSignOutModal); + handleCCSignoutKeyDown(mockEvent, mockSetShowCCSignOutModal, loggerMock); expect(mockSetShowCCSignOutModal).toHaveBeenCalledWith(false); }); it('should not set showCCSignOutModal for other keys', () => { const mockSetShowCCSignOutModal = jest.fn(); const mockEvent = {key: 'Enter'} as React.KeyboardEvent; - handleCCSignoutKeyDown(mockEvent, mockSetShowCCSignOutModal); + handleCCSignoutKeyDown(mockEvent, mockSetShowCCSignOutModal, loggerMock); expect(mockSetShowCCSignOutModal).not.toHaveBeenCalled(); }); }); @@ -76,7 +76,7 @@ describe('Station Login Utils', () => { }); it('should show modal when showMultipleLoginAlert is true and modal is not open', () => { - handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, true, false, false); + handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, true, false, false, loggerMock); expect(mockModalRef.current?.showModal).toHaveBeenCalled(); }); @@ -84,24 +84,24 @@ describe('Station Login Utils', () => { if (mockModalRef.current) { mockModalRef.current.open = true; } - handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, false, false, false); + handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, false, false, false, loggerMock); expect(mockModalRef.current?.close).toHaveBeenCalled(); }); it('should show CC sign out modal when showCCSignOutModal is true', () => { - handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, false, true, false); + handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, false, true, false, loggerMock); expect(mockCCSignOutModalRef.current?.showModal).toHaveBeenCalled(); }); it('should show save confirm dialog when showSaveConfirmDialog is true', () => { - handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, false, false, true); + handleModals(mockModalRef, mockCCSignOutModalRef, mockSaveConfirmDialogRef, false, false, true, loggerMock); expect(mockSaveConfirmDialogRef.current?.showModal).toHaveBeenCalled(); }); it('should handle null refs gracefully', () => { const nullRef = {current: null}; expect(() => { - handleModals(nullRef, nullRef, nullRef, true, true, true); + handleModals(nullRef, nullRef, nullRef, true, true, true, loggerMock); }).not.toThrow(); }); }); @@ -116,7 +116,7 @@ describe('Station Login Utils', () => { const mockHandleContinue = jest.fn(); const mockSetShowCCSignOutModal = jest.fn(); - continueClicked(mockModalRef, mockHandleContinue, mockSetShowCCSignOutModal); + continueClicked(mockModalRef, mockHandleContinue, mockSetShowCCSignOutModal, loggerMock); expect(mockModalRef.current?.close).toHaveBeenCalled(); expect(mockHandleContinue).toHaveBeenCalled(); @@ -129,7 +129,7 @@ describe('Station Login Utils', () => { const mockSetShowCCSignOutModal = jest.fn(); expect(() => { - continueClicked(nullRef, mockHandleContinue, mockSetShowCCSignOutModal); + continueClicked(nullRef, mockHandleContinue, mockSetShowCCSignOutModal, loggerMock); }).not.toThrow(); }); }); @@ -144,7 +144,7 @@ describe('Station Login Utils', () => { }; const mockSetShowCCSignOutModal = jest.fn(); - ccCancelButtonClicked(mockRef, mockSetShowCCSignOutModal); + ccCancelButtonClicked(mockRef, mockSetShowCCSignOutModal, loggerMock); expect(mockRef.current?.close).toHaveBeenCalled(); expect(mockSetShowCCSignOutModal).toHaveBeenCalledWith(false); @@ -159,7 +159,7 @@ describe('Station Login Utils', () => { }; const mockSetShowCCSignOutModal = jest.fn(); - ccCancelButtonClicked(mockRef, mockSetShowCCSignOutModal); + ccCancelButtonClicked(mockRef, mockSetShowCCSignOutModal, loggerMock); expect(mockRef.current?.close).not.toHaveBeenCalled(); expect(mockSetShowCCSignOutModal).not.toHaveBeenCalled(); @@ -170,7 +170,7 @@ describe('Station Login Utils', () => { const mockSetShowCCSignOutModal = jest.fn(); expect(() => { - ccCancelButtonClicked(nullRef, mockSetShowCCSignOutModal); + ccCancelButtonClicked(nullRef, mockSetShowCCSignOutModal, loggerMock); }).not.toThrow(); }); }); @@ -180,7 +180,7 @@ describe('Station Login Utils', () => { const mockSetDialNumberLabel = jest.fn(); const mockSetDialNumberPlaceholder = jest.fn(); - updateDialNumberLabel(DIALNUMBER, mockSetDialNumberLabel, mockSetDialNumberPlaceholder); + updateDialNumberLabel(DIALNUMBER, mockSetDialNumberLabel, mockSetDialNumberPlaceholder, loggerMock); expect(mockSetDialNumberLabel).toHaveBeenCalledWith(LoginOptions[DIALNUMBER]); expect(mockSetDialNumberPlaceholder).toHaveBeenCalledWith(LoginOptions[DIALNUMBER]); @@ -190,7 +190,7 @@ describe('Station Login Utils', () => { const mockSetDialNumberLabel = jest.fn(); const mockSetDialNumberPlaceholder = jest.fn(); - updateDialNumberLabel(EXTENSION, mockSetDialNumberLabel, mockSetDialNumberPlaceholder); + updateDialNumberLabel(EXTENSION, mockSetDialNumberLabel, mockSetDialNumberPlaceholder, loggerMock); expect(mockSetDialNumberLabel).toHaveBeenCalledWith(LoginOptions[EXTENSION]); expect(mockSetDialNumberPlaceholder).toHaveBeenCalledWith(LoginOptions[EXTENSION]); @@ -200,7 +200,7 @@ describe('Station Login Utils', () => { const mockSetDialNumberLabel = jest.fn(); const mockSetDialNumberPlaceholder = jest.fn(); - updateDialNumberLabel(DESKTOP, mockSetDialNumberLabel, mockSetDialNumberPlaceholder); + updateDialNumberLabel(DESKTOP, mockSetDialNumberLabel, mockSetDialNumberPlaceholder, loggerMock); expect(mockSetDialNumberLabel).not.toHaveBeenCalled(); expect(mockSetDialNumberPlaceholder).not.toHaveBeenCalled(); @@ -210,7 +210,7 @@ describe('Station Login Utils', () => { const mockSetDialNumberLabel = jest.fn(); const mockSetDialNumberPlaceholder = jest.fn(); - updateDialNumberLabel('INVALID_OPTION', mockSetDialNumberLabel, mockSetDialNumberPlaceholder); + updateDialNumberLabel('INVALID_OPTION', mockSetDialNumberLabel, mockSetDialNumberPlaceholder, loggerMock); expect(mockSetDialNumberLabel).not.toHaveBeenCalled(); expect(mockSetDialNumberPlaceholder).not.toHaveBeenCalled(); @@ -226,21 +226,21 @@ describe('Station Login Utils', () => { it('should return false for valid dial number with default regex', () => { const validNumber = '15552234567'; // Changed 4th digit from 1 to 2 to match [2-9] pattern - const result = validateDialNumber(validNumber, null, mockSetDNErrorText); + const result = validateDialNumber(validNumber, null, mockSetDNErrorText, loggerMock); expect(result).toBe(false); expect(mockSetDNErrorText).not.toHaveBeenCalled(); }); it('should return true for invalid dial number and set error text', () => { const invalidNumber = '911'; // This should be invalid for the default regex (too short and doesn't match pattern) - const result = validateDialNumber(invalidNumber, null, mockSetDNErrorText); + const result = validateDialNumber(invalidNumber, null, mockSetDNErrorText, loggerMock); expect(result).toBe(true); expect(mockSetDNErrorText).toHaveBeenCalledWith(StationLoginLabels.DN_FORMAT_ERROR); }); it('should accept any input when empty string regex is provided', () => { const anyNumber = '911'; // With empty string regex, this becomes /(?:)/ which matches everything - const result = validateDialNumber(anyNumber, '', mockSetDNErrorText); + const result = validateDialNumber(anyNumber, '', mockSetDNErrorText, loggerMock); expect(result).toBe(false); // Should return false (no error) because empty regex matches everything expect(mockSetDNErrorText).not.toHaveBeenCalled(); }); @@ -248,7 +248,7 @@ describe('Station Login Utils', () => { it('should use custom regex when provided', () => { const customRegex = '^\\d{10}$'; const validNumber = '1234567890'; - const result = validateDialNumber(validNumber, customRegex, mockSetDNErrorText); + const result = validateDialNumber(validNumber, customRegex, mockSetDNErrorText, loggerMock); expect(result).toBe(false); expect(mockSetDNErrorText).not.toHaveBeenCalled(); }); @@ -256,7 +256,7 @@ describe('Station Login Utils', () => { it('should return true for invalid number with custom regex', () => { const customRegex = '^\\d{10}$'; const invalidNumber = '123'; - const result = validateDialNumber(invalidNumber, customRegex, mockSetDNErrorText); + const result = validateDialNumber(invalidNumber, customRegex, mockSetDNErrorText, loggerMock); expect(result).toBe(true); expect(mockSetDNErrorText).toHaveBeenCalledWith(StationLoginLabels.DN_FORMAT_ERROR); }); @@ -279,7 +279,7 @@ describe('Station Login Utils', () => { }; const mockSetShowSaveConfirmDialog = jest.fn(); - saveConfirmCancelClicked(mockRef, mockSetShowSaveConfirmDialog); + saveConfirmCancelClicked(mockRef, mockSetShowSaveConfirmDialog, loggerMock); expect(mockRef.current?.close).toHaveBeenCalled(); expect(mockSetShowSaveConfirmDialog).toHaveBeenCalledWith(false); @@ -290,7 +290,7 @@ describe('Station Login Utils', () => { const mockSetShowSaveConfirmDialog = jest.fn(); expect(() => { - saveConfirmCancelClicked(nullRef, mockSetShowSaveConfirmDialog); + saveConfirmCancelClicked(nullRef, mockSetShowSaveConfirmDialog, loggerMock); }).not.toThrow(); }); }); @@ -306,7 +306,7 @@ describe('Station Login Utils', () => { const mockSetShowSaveConfirmDialog = jest.fn(); const mockSaveLoginOptions = jest.fn(); - handleSaveConfirm(mockRef, mockSetShowSaveConfirmDialog, mockSaveLoginOptions); + handleSaveConfirm(mockRef, mockSetShowSaveConfirmDialog, mockSaveLoginOptions, loggerMock); expect(mockRef.current?.close).toHaveBeenCalled(); expect(mockSetShowSaveConfirmDialog).toHaveBeenCalledWith(false); @@ -726,7 +726,7 @@ describe('Station Login Utils', () => { }; const mockOnCCSignOut = jest.fn(); - handleOnCCSignOut(mockRef, mockOnCCSignOut); + handleOnCCSignOut(mockRef, mockOnCCSignOut, loggerMock); expect(mockRef.current?.close).toHaveBeenCalled(); expect(mockOnCCSignOut).toHaveBeenCalled(); @@ -741,7 +741,7 @@ describe('Station Login Utils', () => { }; const mockOnCCSignOut = jest.fn(); - handleOnCCSignOut(mockRef, mockOnCCSignOut); + handleOnCCSignOut(mockRef, mockOnCCSignOut, loggerMock); expect(mockOnCCSignOut).toHaveBeenCalled(); }); @@ -751,7 +751,7 @@ describe('Station Login Utils', () => { const mockOnCCSignOut = jest.fn(); expect(() => { - handleOnCCSignOut(nullRef, mockOnCCSignOut); + handleOnCCSignOut(nullRef, mockOnCCSignOut, loggerMock); }).not.toThrow(); expect(mockOnCCSignOut).toHaveBeenCalled(); @@ -764,7 +764,7 @@ describe('Station Login Utils', () => { current: document.createElement('dialog'), } as React.RefObject); - const refs = createStationLoginRefs(); + const refs = createStationLoginRefs(loggerMock); expect(useRefMock).toHaveBeenCalledTimes(3); expect(refs).toHaveProperty('multiSignInModalRef'); @@ -775,4 +775,153 @@ describe('Station Login Utils', () => { expect(refs.saveConfirmDialogRef.current).toBeInstanceOf(HTMLDialogElement); }); }); + + describe('Error Handling', () => { + beforeEach(() => { + loggerMock.error.mockClear(); + }); + + it('should log error when handleModals throws exception', () => { + const mockRef = { + current: { + open: false, + showModal: jest.fn(() => { + throw new Error('Test showModal error'); + }), + }, + } as unknown as HTMLDialogElement; + + handleModals(mockRef, null, null, true, false, false, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in handleModals', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleModals', + error: 'Test showModal error', + }); + }); + + it('should log error when continueClicked throws exception', () => { + const mockCallback = jest.fn(() => { + throw new Error('Test callback error'); + }); + const mockRef = {current: {close: jest.fn()}}; + const mockSetShowModal = jest.fn(); + + continueClicked(mockRef, mockCallback, mockSetShowModal, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in continueClicked', { + module: 'cc-components#station-login.utils.tsx', + method: 'continueClicked', + error: 'Test callback error', + }); + }); + + it('should log error when validateDialNumber throws exception and return true', () => { + // Mock RegExp constructor to throw an error + const originalRegExp = global.RegExp; + global.RegExp = jest.fn(() => { + throw new Error('Test validation error'); + }) as unknown as typeof RegExp; + + const mockSetError = jest.fn(); + const result = validateDialNumber('15551234567', null, mockSetError, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in validateDialNumber', { + module: 'cc-components#station-login.utils.tsx', + method: 'validateDialNumber', + error: 'Test validation error', + }); + expect(result).toBe(true); // Should return true to indicate error state + + // Restore original RegExp + global.RegExp = originalRegExp; + }); + + it('should log error when updateDialNumberLabel throws exception', () => { + const mockSetLabel = jest.fn(() => { + throw new Error('Test label error'); + }); + + updateDialNumberLabel(EXTENSION, mockSetLabel, jest.fn(), loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in updateDialNumberLabel', { + module: 'cc-components#station-login.utils.tsx', + method: 'updateDialNumberLabel', + error: 'Test label error', + }); + }); + + it('should log error when handleLoginOptionChanged throws exception', () => { + const event = {detail: {value: EXTENSION}}; + const mockSetters = { + setDeviceType: jest.fn(() => { + throw new Error('Test setter error'); + }), + setSelectedDeviceType: jest.fn(), + setDialNumber: jest.fn(), + setDialNumberValue: jest.fn(), + setCurrentLoginOptions: jest.fn(), + setDialNumberLabel: jest.fn(), + setDialNumberPlaceholder: jest.fn(), + setShowDNError: jest.fn(), + setSelectedTeamId: jest.fn(), + setTeamId: jest.fn(), + }; + + handleLoginOptionChanged( + event, + mockSetters.setDeviceType, + mockSetters.setSelectedDeviceType, + jest.fn(), + mockSetters.setDialNumber, + mockSetters.setDialNumberValue, + mockSetters.setCurrentLoginOptions, + {}, + mockSetters.setDialNumberLabel, + mockSetters.setDialNumberPlaceholder, + mockSetters.setShowDNError, + mockSetters.setSelectedTeamId, + mockSetters.setTeamId, + loggerMock, + 'team123' + ); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in handleLoginOptionChanged', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleLoginOptionChanged', + error: 'Test setter error', + }); + }); + + it('should log error when handleDNInputChanged throws exception', () => { + const event = {target: {value: 'test'}}; + const mockSetters = { + setDialNumberValue: jest.fn(() => { + throw new Error('Test input error'); + }), + setDialNumber: jest.fn(), + setShowDNError: jest.fn(), + setDNErrorText: jest.fn(), + setCurrentLoginOptions: jest.fn(), + }; + + handleDNInputChanged( + event, + mockSetters.setDialNumberValue, + mockSetters.setDialNumber, + mockSetters.setShowDNError, + mockSetters.setDNErrorText, + null, + mockSetters.setCurrentLoginOptions, + EXTENSION, + loggerMock + ); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: StationLogin: Error in handleDNInputChanged', { + module: 'cc-components#station-login.utils.tsx', + method: 'handleDNInputChanged', + error: 'Test input error', + }); + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx b/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx index a046816da..c91712b58 100644 --- a/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx +++ b/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx @@ -152,7 +152,7 @@ describe('UserStateComponent', () => { render(); }); - expect(getPreviousSelectableStateSpy).toHaveBeenCalledWith(defaultProps.idleCodes); + expect(getPreviousSelectableStateSpy).toHaveBeenCalledWith(defaultProps.idleCodes, defaultProps.logger); }); it('should call getSelectedKey with correct parameters', async () => { @@ -163,7 +163,8 @@ describe('UserStateComponent', () => { expect(getSelectedKeySpy).toHaveBeenCalledWith( defaultProps.customState, defaultProps.currentState, - defaultProps.idleCodes + defaultProps.idleCodes, + defaultProps.logger ); }); @@ -175,7 +176,8 @@ describe('UserStateComponent', () => { expect(buildDropdownItemsSpy).toHaveBeenCalledWith( defaultProps.customState, defaultProps.idleCodes, - defaultProps.currentState + defaultProps.currentState, + defaultProps.logger ); }); @@ -184,7 +186,10 @@ describe('UserStateComponent', () => { render(); }); - expect(sortDropdownItemsSpy).toHaveBeenCalledWith(buildDropdownItemsSpy.mock.results[0].value); + expect(sortDropdownItemsSpy).toHaveBeenCalledWith( + buildDropdownItemsSpy.mock.results[0].value, + defaultProps.logger + ); }); it('should call getDropdownClass with correct parameters', async () => { @@ -195,7 +200,8 @@ describe('UserStateComponent', () => { expect(getDropdownClassSpy).toHaveBeenCalledWith( defaultProps.customState, defaultProps.currentState, - defaultProps.idleCodes + defaultProps.idleCodes, + defaultProps.logger ); }); @@ -207,7 +213,8 @@ describe('UserStateComponent', () => { expect(getTooltipTextSpy).toHaveBeenCalledWith( defaultProps.customState, defaultProps.currentState, - defaultProps.idleCodes + defaultProps.idleCodes, + defaultProps.logger ); }); @@ -217,7 +224,7 @@ describe('UserStateComponent', () => { }); // getIconStyle should be called for the items being rendered - expect(getIconStyleSpy).toHaveBeenCalledWith({id: '0', name: 'Available'}); + expect(getIconStyleSpy).toHaveBeenCalledWith({id: '0', name: 'Available'}, defaultProps.logger); }); }); @@ -245,11 +252,17 @@ describe('UserStateComponent', () => { render(); }); - expect(getSelectedKeySpy).toHaveBeenCalledWith(customState, defaultProps.currentState, defaultProps.idleCodes); + expect(getSelectedKeySpy).toHaveBeenCalledWith( + customState, + defaultProps.currentState, + defaultProps.idleCodes, + defaultProps.logger + ); expect(buildDropdownItemsSpy).toHaveBeenCalledWith( customState, defaultProps.idleCodes, - defaultProps.currentState + defaultProps.currentState, + defaultProps.logger ); }); }); @@ -268,7 +281,8 @@ describe('UserStateComponent', () => { expect(getSelectedKeySpy).toHaveBeenLastCalledWith( customState, defaultProps.currentState, - defaultProps.idleCodes + defaultProps.idleCodes, + defaultProps.logger ); }); @@ -280,11 +294,21 @@ describe('UserStateComponent', () => { screen.rerender(); - expect(getSelectedKeySpy).toHaveBeenCalledWith(defaultProps.customState, '3', defaultProps.idleCodes); + expect(getSelectedKeySpy).toHaveBeenCalledWith( + defaultProps.customState, + '3', + defaultProps.idleCodes, + defaultProps.logger + ); screen.rerender(); - expect(getSelectedKeySpy).toHaveBeenCalledWith(defaultProps.customState, '4', defaultProps.idleCodes); + expect(getSelectedKeySpy).toHaveBeenCalledWith( + defaultProps.customState, + '4', + defaultProps.idleCodes, + defaultProps.logger + ); }); it('should handle elapsed time formatting correctly', async () => { @@ -321,7 +345,7 @@ describe('UserStateComponent', () => { // The getIconStyle should have been called with the item that has an empty name // due to the || '' fallback when currentState is not found in idleCodes - expect(getIconStyleSpy).toHaveBeenCalledWith({id: '0', name: 'Available'}); + expect(getIconStyleSpy).toHaveBeenCalledWith({id: '0', name: 'Available'}, defaultProps.logger); }); }); }); diff --git a/packages/contact-center/cc-components/tests/components/UserState/user-state.utils.tsx b/packages/contact-center/cc-components/tests/components/UserState/user-state.utils.tsx index 4a866056e..4b6be2d03 100644 --- a/packages/contact-center/cc-components/tests/components/UserState/user-state.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/UserState/user-state.utils.tsx @@ -34,27 +34,27 @@ describe('UserState Utils', () => { describe('getDropdownClass', () => { it('should return "custom" when customState is present', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = getDropdownClass(customState, '1', mockIdleCodes); + const result = getDropdownClass(customState, '1', mockIdleCodes, loggerMock); expect(result).toBe('custom'); }); it('should return "" for "0"', () => { - const result = getDropdownClass(null, '0', mockIdleCodes); + const result = getDropdownClass(null, '0', mockIdleCodes, loggerMock); expect(result).toBe(''); }); it('should return "rona" for RONA state', () => { - const result = getDropdownClass(null, '3', mockIdleCodes); + const result = getDropdownClass(null, '3', mockIdleCodes, loggerMock); expect(result).toBe('rona'); }); it('should return "idle" for other states', () => { - const result = getDropdownClass(null, '1', mockIdleCodes); + const result = getDropdownClass(null, '1', mockIdleCodes, loggerMock); expect(result).toBe('idle'); }); it('should return "idle" when currentState is not found in idleCodes', () => { - const result = getDropdownClass(null, '999', mockIdleCodes); + const result = getDropdownClass(null, '999', mockIdleCodes, loggerMock); expect(result).toBe('idle'); }); }); @@ -62,7 +62,7 @@ describe('UserState Utils', () => { describe('getIconStyle', () => { it('should return correct style for Available state', () => { const item = {id: '0', name: 'Available'}; - const result = getIconStyle(item); + const result = getIconStyle(item, loggerMock); expect(result).toEqual({ class: 'available', iconName: 'active-presence-small-filled', @@ -71,7 +71,7 @@ describe('UserState Utils', () => { it('should return correct style for RONA state', () => { const item = {id: '3', name: 'RONA'}; - const result = getIconStyle(item); + const result = getIconStyle(item, loggerMock); expect(result).toEqual({ class: 'rona', iconName: 'dnd-presence-filled', @@ -80,7 +80,7 @@ describe('UserState Utils', () => { it('should return default style for other states', () => { const item = {id: '1', name: 'Break'}; - const result = getIconStyle(item); + const result = getIconStyle(item, loggerMock); expect(result).toEqual({ class: 'idle', iconName: 'recents-presence-filled', @@ -89,7 +89,7 @@ describe('UserState Utils', () => { it('should return custom style for items with developerName', () => { const item = {id: '1', name: 'Custom State', developerName: 'CUSTOM'}; - const result = getIconStyle(item); + const result = getIconStyle(item, loggerMock); expect(result).toEqual({ class: 'custom', iconName: 'busy-presence-light', @@ -100,24 +100,24 @@ describe('UserState Utils', () => { describe('getTooltipText', () => { it('should return custom available tooltip when customState is present and current state is Available', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = getTooltipText(customState, '0', mockIdleCodes); + const result = getTooltipText(customState, '0', mockIdleCodes, loggerMock); expect(result).toBe(userStateLabels.customWithAvailableTooltip); }); it('should return custom idle tooltip when customState is present and current state is not Available', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = getTooltipText(customState, '1', mockIdleCodes); + const result = getTooltipText(customState, '1', mockIdleCodes, loggerMock); expect(result).toBe(userStateLabels.customWithIdleStateTooltip.replace('{{currentState}}', 'Break')); }); it('should return default tooltip when no customState', () => { - const result = getTooltipText(null, '1', mockIdleCodes); + const result = getTooltipText(null, '1', mockIdleCodes, loggerMock); expect(result).toBe(userStateLabels.availableTooltip); }); it('should handle missing currentState in idleCodes', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = getTooltipText(customState, '999', mockIdleCodes); + const result = getTooltipText(customState, '999', mockIdleCodes, loggerMock); expect(result).toBe(userStateLabels.customWithIdleStateTooltip.replace('{{currentState}}', '')); }); }); @@ -167,7 +167,7 @@ describe('UserState Utils', () => { {id: '3', name: 'Lunch'}, ]; - const result = sortDropdownItems(items); + const result = sortDropdownItems(items, loggerMock); expect(result).toEqual([ {id: '0', name: 'Available'}, @@ -178,7 +178,7 @@ describe('UserState Utils', () => { }); it('should handle empty array', () => { - const result = sortDropdownItems([]); + const result = sortDropdownItems([], loggerMock); expect(result).toEqual([]); }); @@ -189,7 +189,7 @@ describe('UserState Utils', () => { ]; const originalItems = [...items]; - sortDropdownItems(items); + sortDropdownItems(items, loggerMock); expect(items).toEqual(originalItems); }); @@ -201,7 +201,7 @@ describe('UserState Utils', () => { ]; const originalItems = [...items]; - sortDropdownItems(items); + sortDropdownItems(items, loggerMock); expect(items).toEqual(originalItems); }); @@ -209,7 +209,7 @@ describe('UserState Utils', () => { describe('getPreviousSelectableState', () => { it('should return first non-RONA/Engaged state', () => { - const result = getPreviousSelectableState(mockIdleCodes); + const result = getPreviousSelectableState(mockIdleCodes, loggerMock); expect(result).toBe('0'); // Available }); @@ -219,12 +219,12 @@ describe('UserState Utils', () => { {id: '4', name: 'ENGAGED', isSystem: true, isDefault: false}, ]; - const result = getPreviousSelectableState(nonSelectableStates); + const result = getPreviousSelectableState(nonSelectableStates, loggerMock); expect(result).toBe('0'); }); it('should handle empty idleCodes array', () => { - const result = getPreviousSelectableState([]); + const result = getPreviousSelectableState([], loggerMock); expect(result).toBe('0'); }); }); @@ -232,34 +232,34 @@ describe('UserState Utils', () => { describe('getSelectedKey', () => { it('should return custom key when customState is present', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = getSelectedKey(customState, '1', mockIdleCodes); + const result = getSelectedKey(customState, '1', mockIdleCodes, loggerMock); expect(result).toBe('hide-CUSTOM'); }); it('should return currentState for normal states', () => { - const result = getSelectedKey(null, '1', mockIdleCodes); + const result = getSelectedKey(null, '1', mockIdleCodes, loggerMock); expect(result).toBe('1'); }); it('should return hide-prefixed key for RONA when it is current state', () => { - const result = getSelectedKey(null, '3', mockIdleCodes); + const result = getSelectedKey(null, '3', mockIdleCodes, loggerMock); expect(result).toBe('hide-3'); }); it('should return previous selectable state for ENGAGED', () => { - const result = getSelectedKey(null, '4', mockIdleCodes); + const result = getSelectedKey(null, '4', mockIdleCodes, loggerMock); expect(result).toBe('0'); }); it('should handle missing currentState in idleCodes', () => { - const result = getSelectedKey(null, '999', mockIdleCodes); + const result = getSelectedKey(null, '999', mockIdleCodes, loggerMock); expect(result).toBe('999'); }); }); describe('buildDropdownItems', () => { it('should filter out RONA and ENGAGED states', () => { - const result = buildDropdownItems(null, mockIdleCodes, '1'); + const result = buildDropdownItems(null, mockIdleCodes, '1', loggerMock); expect(result).toEqual([ {id: '0', name: 'Available'}, @@ -270,7 +270,7 @@ describe('UserState Utils', () => { it('should include custom state when present', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = buildDropdownItems(customState, mockIdleCodes, '1'); + const result = buildDropdownItems(customState, mockIdleCodes, '1', loggerMock); expect(result).toEqual([ {id: 'hide-CUSTOM', name: 'Custom State', developerName: 'CUSTOM'}, @@ -281,13 +281,13 @@ describe('UserState Utils', () => { }); it('should handle empty idleCodes array', () => { - const result = buildDropdownItems(null, [], '1'); + const result = buildDropdownItems(null, [], '1', loggerMock); expect(result).toEqual([]); }); it('should handle empty idleCodes array with custom state', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = buildDropdownItems(customState, [], '1'); + const result = buildDropdownItems(customState, [], '1', loggerMock); expect(result).toEqual([{id: 'hide-CUSTOM', name: 'Custom State', developerName: 'CUSTOM'}]); }); @@ -298,12 +298,12 @@ describe('UserState Utils', () => { {id: '4', name: 'ENGAGED', isSystem: true, isDefault: false}, ]; - const result = buildDropdownItems(null, ronaEngagedOnly, '1'); + const result = buildDropdownItems(null, ronaEngagedOnly, '1', loggerMock); expect(result).toEqual([]); }); it('should include RONA when it is the current state', () => { - const result = buildDropdownItems(null, mockIdleCodes, '3'); + const result = buildDropdownItems(null, mockIdleCodes, '3', loggerMock); expect(result).toEqual([ {id: '0', name: 'Available'}, @@ -315,7 +315,7 @@ describe('UserState Utils', () => { it('should use correct prefix for custom state', () => { const customState = {name: 'Custom State', developerName: 'CUSTOM'}; - const result = buildDropdownItems(customState, mockIdleCodes, '1'); + const result = buildDropdownItems(customState, mockIdleCodes, '1', loggerMock); expect(result[0]).toEqual({ id: 'hide-CUSTOM', @@ -324,4 +324,180 @@ describe('UserState Utils', () => { }); }); }); + + describe('Error Handling', () => { + beforeEach(() => { + loggerMock.error.mockClear(); + }); + + it('should log error when getDropdownClass throws exception', () => { + // Create corrupted idleCodes that will throw when iterating in the for loop + const corruptedIdleCodes = new Proxy(mockIdleCodes, { + get(target, prop) { + if (prop === Symbol.iterator) { + return function () { + throw new Error('Test getDropdownClass error'); + }; + } + return target[prop]; + }, + }); + + const result = getDropdownClass(null, '3', corruptedIdleCodes, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in getDropdownClass', { + module: 'cc-components#user-state.utils.ts', + method: 'getDropdownClass', + error: 'Test getDropdownClass error', + }); + expect(result).toBe('idle'); // Should return default fallback + }); + + it('should log error when getIconStyle throws exception', () => { + const item = {id: '0', name: 'Available'}; + // Mock the switch statement to throw an error by corrupting the item + const corruptedItem = new Proxy(item, { + get() { + throw new Error('Test getIconStyle error'); + }, + }); + + const result = getIconStyle(corruptedItem, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in getIconStyle', { + module: 'cc-components#user-state.utils.ts', + method: 'getIconStyle', + error: 'Test getIconStyle error', + }); + expect(result).toEqual({class: 'idle', iconName: 'recents-presence-filled'}); + }); + + it('should log error when getTooltipText throws exception', () => { + const customState = {name: 'Custom State', developerName: 'CUSTOM'}; + // Mock array.find to throw an error + const corruptedIdleCodes = new Proxy(mockIdleCodes, { + get(target, prop) { + if (prop === 'find') { + return () => { + throw new Error('Test getTooltipText error'); + }; + } + return target[prop]; + }, + }); + + const result = getTooltipText(customState, '1', corruptedIdleCodes, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in getTooltipText', { + module: 'cc-components#user-state.utils.ts', + method: 'getTooltipText', + error: 'Test getTooltipText error', + }); + expect(result).toBe(userStateLabels.availableTooltip); + }); + + it('should log error when handleSelectionChange throws exception', () => { + const mockSetAgentStatus = jest.fn(() => { + throw new Error('Test handleSelectionChange error'); + }); + + handleSelectionChange('2', '1', mockSetAgentStatus, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in handleSelectionChange', { + module: 'cc-components#user-state.utils.ts', + method: 'handleSelectionChange', + error: 'Test handleSelectionChange error', + }); + }); + + it('should log error when sortDropdownItems throws exception', () => { + // Create corrupted items that will throw on filter operations + const items = [ + {id: '0', name: 'Available'}, + {id: '1', name: 'Break'}, + ]; + const corruptedItems = new Proxy(items, { + get(target, prop) { + if (prop === 'filter') { + return () => { + throw new Error('Test sortDropdownItems error'); + }; + } + return target[prop]; + }, + }); + + const result = sortDropdownItems(corruptedItems, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in sortDropdownItems', { + module: 'cc-components#user-state.utils.ts', + method: 'sortDropdownItems', + error: 'Test sortDropdownItems error', + }); + expect(result).toEqual(items); // Should return original array as fallback + }); + + it('should log error when getPreviousSelectableState throws exception', () => { + // Mock array.find to throw an error + const corruptedIdleCodes = new Proxy(mockIdleCodes, { + get(target, prop) { + if (prop === 'find') { + return () => { + throw new Error('Test getPreviousSelectableState error'); + }; + } + return target[prop]; + }, + }); + + const result = getPreviousSelectableState(corruptedIdleCodes, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in getPreviousSelectableState', { + module: 'cc-components#user-state.utils.ts', + method: 'getPreviousSelectableState', + error: 'Test getPreviousSelectableState error', + }); + expect(result).toBe('0'); // Should return default fallback + }); + + it('should log error when getSelectedKey throws exception', () => { + // Mock Object.values to throw an error + const originalValues = Object.values; + Object.values = jest.fn(() => { + throw new Error('Test getSelectedKey error'); + }); + + const result = getSelectedKey(null, '1', mockIdleCodes, loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in getSelectedKey', { + module: 'cc-components#user-state.utils.ts', + method: 'getSelectedKey', + error: 'Test getSelectedKey error', + }); + expect(result).toBe('1'); // Should return original state as fallback + + // Restore original function + Object.values = originalValues; + }); + + it('should log error when buildDropdownItems throws exception', () => { + // Mock Object.values to throw an error + const originalValues = Object.values; + Object.values = jest.fn(() => { + throw new Error('Test buildDropdownItems error'); + }); + + const result = buildDropdownItems(null, mockIdleCodes, '1', loggerMock); + + expect(loggerMock.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in buildDropdownItems', { + module: 'cc-components#user-state.utils.ts', + method: 'buildDropdownItems', + error: 'Test buildDropdownItems error', + }); + expect(result).toEqual([]); // Should return empty array as fallback + + // Restore original function + Object.values = originalValues; + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx b/packages/contact-center/cc-components/tests/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx index 4f9afb52b..2f070e76f 100644 --- a/packages/contact-center/cc-components/tests/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx +++ b/packages/contact-center/cc-components/tests/components/task/AutoWrapupTimer/AutoWrapupTimer.tsx @@ -8,11 +8,19 @@ import {UNTIL_AUTO_WRAPUP, CANCEL} from '../../../../src/components/task/constan describe('AutoWrapupTimer', () => { const mockHandleCancelWrapup = jest.fn(); + const loggerMock = { + error: jest.fn(), + info: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }; const defaultProps: AutoWrapupTimerProps = { secondsUntilAutoWrapup: 30, allowCancelAutoWrapup: false, handleCancelWrapup: mockHandleCancelWrapup, + logger: loggerMock, }; const getTimerUIStateSpy = jest.spyOn(autoWrapupUtils, 'getTimerUIState'); @@ -32,7 +40,7 @@ describe('AutoWrapupTimer', () => { expect(screen.getByText(UNTIL_AUTO_WRAPUP)).toBeInTheDocument(); expect(screen.getByText('00:30')).toBeInTheDocument(); - expect(getTimerUIStateSpy).toHaveBeenCalledWith(30); + expect(getTimerUIStateSpy).toHaveBeenCalledWith(30, loggerMock); const normalListItem = normalContainer.querySelector('mdc-listitem'); const normalIcon = normalContainer.querySelector('mdc-icon'); @@ -64,7 +72,7 @@ describe('AutoWrapupTimer', () => { expect(screen.getByText('00:05')).toBeInTheDocument(); expect(screen.getByText(CANCEL)).toBeInTheDocument(); - expect(getTimerUIStateSpy).toHaveBeenCalledWith(5); + expect(getTimerUIStateSpy).toHaveBeenCalledWith(5, loggerMock); const urgentListItem = urgentContainer.querySelector('mdc-listitem'); const urgentIcon = urgentContainer.querySelector('mdc-icon'); @@ -94,7 +102,7 @@ describe('AutoWrapupTimer', () => { const {container: zeroContainer} = await render(); expect(screen.getByText('00:00')).toBeInTheDocument(); - expect(getTimerUIStateSpy).toHaveBeenCalledWith(0); + expect(getTimerUIStateSpy).toHaveBeenCalledWith(0, loggerMock); const zeroListItem = zeroContainer.querySelector('mdc-listitem'); const zeroIcon = zeroContainer.querySelector('mdc-icon'); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index bbc77b0a0..93e707a43 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -776,9 +776,9 @@ describe('CallControl Utils', () => { {id: 'code-2', name: 'Customer Inquiry'}, ]; - handleWrapupReasonChange(mockEvent, mockWrapupCodes, mockHandleWrapupChange); + handleWrapupReasonChange(mockEvent, mockWrapupCodes, mockHandleWrapupChange, loggerMock); - expect(mockHandleWrapupChange).toHaveBeenCalledWith('Technical Issue', 'code-1'); + expect(mockHandleWrapupChange).toHaveBeenCalledWith('Technical Issue', 'code-1', loggerMock); }); it('should handle wrapup reason change with unknown selection', () => { diff --git a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.snapshot.tsx index 90d0aaf25..7e35cf0d5 100644 --- a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.snapshot.tsx @@ -30,6 +30,13 @@ describe('IncomingTaskComponent', () => { // Mock functions const mockAccept = jest.fn(); const mockReject = jest.fn(); + const loggerMock = { + error: jest.fn(), + info: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }; // Default props using IncomingTaskComponentProps interface const defaultProps: IncomingTaskComponentProps = { @@ -37,6 +44,7 @@ describe('IncomingTaskComponent', () => { isBrowser: true, accept: mockAccept, reject: mockReject, + logger: loggerMock, }; // Utility function spies diff --git a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.tsx b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.tsx index 87dafb3a5..1be995f06 100644 --- a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.tsx +++ b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.tsx @@ -30,6 +30,13 @@ describe('IncomingTaskComponent', () => { // Mock functions const mockAccept = jest.fn(); const mockReject = jest.fn(); + const loggerMock = { + error: jest.fn(), + info: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }; // Default props using IncomingTaskComponentProps interface const defaultProps: IncomingTaskComponentProps = { @@ -37,6 +44,7 @@ describe('IncomingTaskComponent', () => { isBrowser: true, accept: mockAccept, reject: mockReject, + logger: loggerMock, }; // Utility function spies @@ -208,10 +216,10 @@ describe('IncomingTaskComponent', () => { // Verify utility function was called correctly for each task expect(extractIncomingTaskDataSpy).toHaveBeenCalledTimes(4); - expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(1, telephonyWebRTCTask, true); - expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(2, telephonyExtensionTask, false); - expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(3, chatTask, true); - expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(4, socialTask, true); + expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(1, telephonyWebRTCTask, true, loggerMock); + expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(2, telephonyExtensionTask, false, loggerMock); + expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(3, chatTask, true, loggerMock); + expect(extractIncomingTaskDataSpy).toHaveBeenNthCalledWith(4, socialTask, true, loggerMock); // === WebRTC Telephony Task Assertions === const webRTCListItem = webRTCContainer.querySelector('li.task-list-item'); diff --git a/packages/contact-center/cc-widgets/package.json b/packages/contact-center/cc-widgets/package.json index 7fbc358e9..4fd3714e1 100644 --- a/packages/contact-center/cc-widgets/package.json +++ b/packages/contact-center/cc-widgets/package.json @@ -95,4 +95,4 @@ "^.+\\.(css|less|scss)$": "babel-jest" } } -} \ No newline at end of file +} diff --git a/packages/contact-center/station-login/package.json b/packages/contact-center/station-login/package.json index 059ec7dcf..1a459b51e 100644 --- a/packages/contact-center/station-login/package.json +++ b/packages/contact-center/station-login/package.json @@ -23,7 +23,8 @@ "dependencies": { "@webex/cc-components": "workspace:*", "@webex/cc-store": "workspace:*", - "mobx-react-lite": "^4.1.0" + "mobx-react-lite": "^4.1.0", + "react-error-boundary": "^6.0.0" }, "devDependencies": { "@babel/core": "7.25.2", @@ -65,4 +66,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/station-login/src/helper.ts b/packages/contact-center/station-login/src/helper.ts index fd9603a37..6aa22c3ce 100644 --- a/packages/contact-center/station-login/src/helper.ts +++ b/packages/contact-center/station-login/src/helper.ts @@ -27,9 +27,16 @@ export const useStationLogin = (props: UseStationLoginProps) => { // useEffect to be called on mount useEffect(() => { - setSelectedDeviceType(deviceType || ''); - setDialNumberValue(dialNumber || ''); - setSelectedTeamId(teamId || ''); + try { + setSelectedDeviceType(deviceType || ''); + setDialNumberValue(dialNumber || ''); + setSelectedTeamId(teamId || ''); + } catch (error) { + logger.error(`CC-Widgets: Error in useEffect (mount) - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'useEffect', + }); + } }, [isAgentLoggedIn]); // Track original and current login options as a single object @@ -49,16 +56,23 @@ export const useStationLogin = (props: UseStationLoginProps) => { // Set original login options after successful login useEffect(() => { - if (store.isAgentLoggedIn) { - setOriginalLoginOptions({ - deviceType: store.deviceType, - dialNumber: store.dialNumber, - teamId: store.teamId || '', - }); - setCurrentLoginOptions({ - deviceType: store.deviceType, - dialNumber: store.dialNumber, - teamId: store.teamId || '', + try { + if (store.isAgentLoggedIn) { + setOriginalLoginOptions({ + deviceType: store.deviceType, + dialNumber: store.dialNumber, + teamId: store.teamId || '', + }); + setCurrentLoginOptions({ + deviceType: store.deviceType, + dialNumber: store.dialNumber, + teamId: store.teamId || '', + }); + } + } catch (error) { + logger.error(`CC-Widgets: Error in useEffect (setOriginalLoginOptions) - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'useEffect', }); } }, [store.isAgentLoggedIn]); @@ -71,68 +85,98 @@ export const useStationLogin = (props: UseStationLoginProps) => { originalLoginOptions.teamId !== currentLoginOptions.teamId; const saveLoginOptions = () => { - setSaveError(''); - if (!isLoginOptionsChanged) { - setSaveError('No changes detected in login options.'); - logger.log('No changes detected in login options.', { - module: 'widget-station-login#helper.ts', - method: 'saveLoginOptions', - }); - if (props.onSaveEnd) props.onSaveEnd(false); - return; - } + try { + setSaveError(''); + if (!isLoginOptionsChanged) { + setSaveError('No changes detected in login options.'); + logger.log('No changes detected in login options.', { + module: 'widget-station-login#helper.ts', + method: 'saveLoginOptions', + }); + if (props.onSaveEnd) props.onSaveEnd(false); + return; + } - if (props.onSaveStart) props.onSaveStart(); + if (props.onSaveStart) props.onSaveStart(); - // Prepare payload for updateAgentProfile - const payload: AgentProfileUpdate = { - loginOption: currentLoginOptions.deviceType as LoginOption, - teamId: currentLoginOptions.teamId || undefined, - }; - if (currentLoginOptions.deviceType !== 'BROWSER') { - payload.dialNumber = currentLoginOptions.dialNumber; - } + // Prepare payload for updateAgentProfile + const payload: AgentProfileUpdate = { + loginOption: currentLoginOptions.deviceType as LoginOption, + teamId: currentLoginOptions.teamId || undefined, + }; + if (currentLoginOptions.deviceType !== 'BROWSER') { + payload.dialNumber = currentLoginOptions.dialNumber; + } - logger.log('Saving login options:', { - module: 'widget-station-login#helper.ts', - method: 'saveLoginOptions', - }); + logger.log('Saving login options:', { + module: 'widget-station-login#helper.ts', + method: 'saveLoginOptions', + }); - cc.updateAgentProfile(payload) - .then(() => { - setOriginalLoginOptions({...currentLoginOptions}); - setSaveError(''); - logger.log('Agent profile updated successfully.', { - module: 'widget-station-login#helper.ts', - method: 'saveLoginOptions', + cc.updateAgentProfile(payload) + .then(() => { + setOriginalLoginOptions({...currentLoginOptions}); + setSaveError(''); + logger.log('Agent profile updated successfully.', { + module: 'widget-station-login#helper.ts', + method: 'saveLoginOptions', + }); + if (props.onSaveEnd) props.onSaveEnd(true); + }) + .catch((error: Error) => { + logger.error('Failed to update agent device type', { + module: 'widget-station-login#helper.ts', + method: 'saveLoginOptions', + }); + setSaveError(error.message || 'Failed to update device type'); + if (props.onSaveEnd) props.onSaveEnd(false); }); - if (props.onSaveEnd) props.onSaveEnd(true); - }) - .catch((error: Error) => { - logger.error('Failed to update agent device type', { - module: 'widget-station-login#helper.ts', - method: 'saveLoginOptions', - }); - setSaveError(error.message || 'Failed to update device type'); - if (props.onSaveEnd) props.onSaveEnd(false); + } catch (error) { + logger.error(`CC-Widgets: Error in saveLoginOptions - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'saveLoginOptions', }); + setSaveError('Failed to save login options'); + if (props.onSaveEnd) props.onSaveEnd(false); + } }; useEffect(() => { - if (loginCb && store.isAgentLoggedIn) { - loginCb(); + try { + if (loginCb && store.isAgentLoggedIn) { + loginCb(); + } + } catch (error) { + logger.error(`CC-Widgets: Error in useEffect (loginCb) - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'useEffect', + }); } }, []); const handleLogout = () => { - if (logoutCb) { - logoutCb(); + try { + if (logoutCb) { + logoutCb(); + } + } catch (error) { + logger.error(`CC-Widgets: Error in handleLogout - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'handleLogout', + }); } }; const handleLogin = () => { - if (loginCb) { - loginCb(); + try { + if (loginCb) { + loginCb(); + } + } catch (error) { + logger.error(`CC-Widgets: Error in handleLogin - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'handleLogin', + }); } }; @@ -153,14 +197,21 @@ export const useStationLogin = (props: UseStationLoginProps) => { // Make sure to set the callback are same and change the logout logic useEffect(() => { - store.setCCCallback(CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, handleLogin); - store.setCCCallback(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogout); - - // TODO: WHen we close this event listener it closes the event listener from storeEventWrapper - // return () => { - // store.removeCCCallback(CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, handleLogin); - // store.removeCCCallback(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogout); - // }; + try { + store.setCCCallback(CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, handleLogin); + store.setCCCallback(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogout); + + // TODO: WHen we close this event listener it closes the event listener from storeEventWrapper + // return () => { + // store.removeCCCallback(CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, handleLogin); + // store.removeCCCallback(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogout); + // }; + } catch (error) { + logger.error(`CC-Widgets: Error in useEffect (setCCCallback) - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'useEffect', + }); + } }, [store.isAgentLoggedIn]); const handleContinue = async () => { @@ -187,49 +238,76 @@ export const useStationLogin = (props: UseStationLoginProps) => { }; const login = () => { - cc.stationLogin({teamId: team, loginOption: deviceType, dialNumber}) - .then((res: StationLoginSuccessResponse) => { - logger.log('CC-Widgets: useStationLogin login(): stationLogin success', { - module: 'widget-station-login#helper.ts', - method: 'login', - }); - setLoginSuccess(res); - setLoginFailure(undefined); - }) - .catch((error: Error) => { - logger.error(`Error logging in: ${error}`, { - module: 'widget-station-login#helper.ts', - method: 'login', + try { + cc.stationLogin({teamId: team, loginOption: deviceType, dialNumber}) + .then((res: StationLoginSuccessResponse) => { + logger.log('CC-Widgets: useStationLogin login(): stationLogin success', { + module: 'widget-station-login#helper.ts', + method: 'login', + }); + setLoginSuccess(res); + setLoginFailure(undefined); + }) + .catch((error: Error) => { + logger.error(`Error logging in: ${error}`, { + module: 'widget-station-login#helper.ts', + method: 'login', + }); + setLoginSuccess(undefined); + setLoginFailure(error); }); - setLoginSuccess(undefined); - setLoginFailure(error); + } catch (error) { + logger.error(`CC-Widgets: Error in login - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'login', }); + setLoginSuccess(undefined); + setLoginFailure(error); + } }; const logout = () => { - logger.info('CC-Widgets: useStationLogin logout(): invoking stationLogout', { - module: 'widget-station-login#helper.ts', - method: 'logout', - }); - cc.stationLogout({logoutReason: 'User requested logout'}) - .then((res: LogoutSuccess) => { - logger.log('CC-Widgets: useStationLogin logout(): stationLogout success', { - module: 'widget-station-login#helper.ts', - method: 'logout', - }); - setLogoutSuccess(res); - }) - .catch((error: Error) => { - logger.error(`CC-Widgets: Error logging out: ${error}`, { - module: 'widget-station-login#helper.ts', - method: 'logout', + try { + logger.info('CC-Widgets: useStationLogin logout(): invoking stationLogout', { + module: 'widget-station-login#helper.ts', + method: 'logout', + }); + cc.stationLogout({logoutReason: 'User requested logout'}) + .then((res: LogoutSuccess) => { + logger.log('CC-Widgets: useStationLogin logout(): stationLogout success', { + module: 'widget-station-login#helper.ts', + method: 'logout', + }); + setLogoutSuccess(res); + }) + .catch((error: Error) => { + logger.error(`CC-Widgets: Error logging out: ${error}`, { + module: 'widget-station-login#helper.ts', + method: 'logout', + }); }); + } catch (error) { + logger.error(`CC-Widgets: Error in logout - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'logout', + }); + } + }; + + const handleSetTeam = (teamValue: string) => { + try { + setTeam(teamValue); + } catch (error) { + logger.error(`CC-Widgets: Error in setTeam - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'setTeam', }); + } }; return { name: 'StationLogin', - setTeam, + setTeam: handleSetTeam, login, logout, loginSuccess, diff --git a/packages/contact-center/station-login/src/station-login/index.tsx b/packages/contact-center/station-login/src/station-login/index.tsx index 04cdbec20..ef4febfa0 100644 --- a/packages/contact-center/station-login/src/station-login/index.tsx +++ b/packages/contact-center/station-login/src/station-login/index.tsx @@ -1,12 +1,13 @@ import React from 'react'; import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import {StationLoginComponent, StationLoginComponentProps} from '@webex/cc-components'; import {useStationLogin} from '../helper'; import {StationLoginProps} from './station-login.types'; -const StationLogin: React.FunctionComponent = observer( +const StationLoginInternal: React.FunctionComponent = observer( ({onLogin, onLogout, onCCSignOut, profileMode, onSaveStart, onSaveEnd, doStationLogout}) => { const { cc, @@ -58,4 +59,18 @@ const StationLogin: React.FunctionComponent = observer( } ); +// Main component wrapped with ErrorBoundary +const StationLogin: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('StationLogin', error); + }} + > + + + ); +}; + export {StationLogin}; diff --git a/packages/contact-center/station-login/tests/helper.ts b/packages/contact-center/station-login/tests/helper.ts index 57836f7f9..120f628aa 100644 --- a/packages/contact-center/station-login/tests/helper.ts +++ b/packages/contact-center/station-login/tests/helper.ts @@ -708,6 +708,198 @@ describe('useStationLogin Hook', () => { ); }); + describe('Error Handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle errors in saveLoginOptions main logic', () => { + const mockOnSaveEnd = jest.fn(); + const mockCC = { + ...ccMock, + updateAgentProfile: jest.fn().mockImplementation(() => { + throw new Error('Test error in saveLoginOptions'); + }), + }; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + cc: mockCC, + onSaveEnd: mockOnSaveEnd, + }) + ); + + act(() => { + result.current.setCurrentLoginOptions({ + deviceType: 'EXTENSION', + dialNumber: '999999999', + teamId: 'team123', + }); + }); + + act(() => { + result.current.saveLoginOptions(); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Error in saveLoginOptions - Test error in saveLoginOptions', + { + module: 'widget-station-login#helper.ts', + method: 'saveLoginOptions', + } + ); + expect(mockOnSaveEnd).toHaveBeenCalledWith(false); + }); + + it('should handle errors in main logout method', () => { + const mockCC = { + ...ccMock, + stationLogout: jest.fn().mockImplementation(() => { + throw new Error('Test error in logout method'); + }), + }; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + cc: mockCC, + }) + ); + + act(() => { + result.current.logout(); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Error in logout - Test error in logout method', { + module: 'widget-station-login#helper.ts', + method: 'logout', + }); + }); + + it('should handle errors in main login method', () => { + const mockCC = { + ...ccMock, + stationLogin: jest.fn().mockImplementation(() => { + throw new Error('Test error in login method'); + }), + }; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + cc: mockCC, + }) + ); + + act(() => { + result.current.setTeam('team123'); + }); + + act(() => { + result.current.login(); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Error in login - Test error in login method', { + module: 'widget-station-login#helper.ts', + method: 'login', + }); + }); + + it('should handle errors when stationLogout is unavailable', () => { + const mockCC = { + ...ccMock, + stationLogout: undefined, // This will cause an error when trying to call it + }; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + cc: mockCC, + }) + ); + + act(() => { + result.current.logout(); + }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/^CC-Widgets: Error in logout/), { + module: 'widget-station-login#helper.ts', + method: 'logout', + }); + }); + + it('should handle errors in setTeam gracefully', () => { + const {result} = renderHook(() => useStationLogin(baseStationLoginProps)); + + // Test that setTeam works without errors for normal case + act(() => { + result.current.setTeam('test-team'); + }); + + // The actual error handling in setTeam is internal to React state management + // So we verify it doesn't throw and handles gracefully + expect(() => { + act(() => { + result.current.setTeam('another-team'); + }); + }).not.toThrow(); + + // We can't easily test internal try-catch of useState, but we ensure + // the wrapper function works correctly + expect(result.current.setTeam).toBeDefined(); + expect(typeof result.current.setTeam).toBe('function'); + }); + + it('should handle errors in useEffect initialization gracefully', () => { + // Test that useEffect error handling doesn't break the hook + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + dialNumber: '12345', + teamId: 'testTeam', + }) + ); + + // Verify the hook still works correctly even with potential internal errors + expect(result.current.selectedDeviceType).toBe('BROWSER'); + expect(result.current.dialNumberValue).toBe('12345'); + expect(result.current.selectedTeamId).toBe('testTeam'); + }); + + it('should handle callback errors in login/logout handlers', () => { + const mockLoginCallback = jest.fn(() => { + throw new Error('Login callback error'); + }); + + const mockLogoutCallback = jest.fn(() => { + throw new Error('Logout callback error'); + }); + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + onLogin: mockLoginCallback, + onLogout: mockLogoutCallback, + }) + ); + + // These should not throw errors due to our try-catch wrappers + expect(() => { + act(() => { + result.current.login(); + }); + }).not.toThrow(); + + expect(() => { + act(() => { + result.current.logout(); + }); + }).not.toThrow(); + }); + }); + describe('#onCCSignOut', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/contact-center/station-login/tests/station-login/index.tsx b/packages/contact-center/station-login/tests/station-login/index.tsx index 7c49ba995..c3cf16bd6 100644 --- a/packages/contact-center/station-login/tests/station-login/index.tsx +++ b/packages/contact-center/station-login/tests/station-login/index.tsx @@ -3,6 +3,7 @@ import {render} from '@testing-library/react'; import {StationLogin} from '../../src'; import * as helper from '../../src/helper'; import '@testing-library/jest-dom'; +import store from '@webex/cc-store'; const teamsMock = ['team123', 'team456']; const ccMock = { @@ -13,7 +14,6 @@ const loginOptionsMock = ['EXTENSION', 'AGENT_DN', 'BROWSER']; const deviceTypeMock = 'BROWSER'; const dialNumberMock = '12345'; const dialNumberRegexMock = '1[0-9]{3}[2-9][0-9]{6}([,]{1,10}[0-9]+){0,1}'; -const loggerMock = {}; const isAgentLoggedInMock = false; // Mock the store import @@ -28,7 +28,12 @@ jest.mock('@webex/cc-store', () => { deviceType: deviceTypeMock, dialNumber: dialNumberMock, dialNumberRegex: dialNumberRegexMock, - logger: loggerMock, + logger: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, isAgentLoggedIn: isAgentLoggedInMock, setCCCallback: jest.fn(), setLogoutCallback: jest.fn(), @@ -36,12 +41,7 @@ jest.mock('@webex/cc-store', () => { CC_EVENTS: { AGENT_STATION_LOGIN_SUCCESS: 'AgentStationLoginSuccess', }, - }; -}); - -jest.mock('@webex/cc-components', () => { - return { - StationLoginComponent: () =>
StationLoginComponent
, + onErrorCallback: jest.fn(), }; }); @@ -52,6 +52,16 @@ const onSaveStart = jest.fn(); const onSaveEnd = jest.fn(); describe('StationLogin Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error for error boundary tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders StationLoginPresentational with correct props', () => { const useStationLoginSpy = jest.spyOn(helper, 'useStationLogin'); @@ -71,7 +81,7 @@ describe('StationLogin Component', () => { cc: ccMock, onLogin: loginCb, onLogout: logoutCb, - logger: loggerMock, + logger: expect.any(Object), deviceType: deviceTypeMock, dialNumber: dialNumberMock, isAgentLoggedIn: false, @@ -82,4 +92,21 @@ describe('StationLogin Component', () => { doStationLogout: undefined, }); }); + + describe('ErrorBoundary Tests', () => { + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + it('should render empty fragment when ErrorBoundary catches an error', () => { + // Mock the StationLoginInternal to throw an error by overriding the helper + jest.spyOn(helper, 'useStationLogin').mockImplementation(() => { + throw new Error('Test error in useStationLogin'); + }); + + const {container} = render(); + + // The fallback should render an empty fragment (no content) + expect(container.firstChild).toBeNull(); + expect(store.onErrorCallback).toHaveBeenCalledWith('StationLogin', Error('Test error in useStationLogin')); + }); + }); }); diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index f0c9f5690..1d6b28ebc 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -59,4 +59,4 @@ "webpack-cli": "5.1.4", "webpack-merge": "6.0.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 45083c69b..f85239ed1 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -116,6 +116,7 @@ interface IStore { interface IStoreWrapper extends IStore { store: IStore; + onErrorCallback?: (widgetName: string, error: Error) => void; setCurrentTask(task: ITask): void; refreshTaskList(): void; setDeviceType(option: string): void; @@ -135,6 +136,7 @@ interface IStoreWrapper extends IStore { setAgentProfile(profile: Profile): void; setTeamId(id: string): void; setIsMuted(value: boolean): void; + setOnError(callback: (widgetName: string, error: Error) => void): void; } interface IWrapupCode { diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4522c303f..19d3a03f8 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -28,6 +28,7 @@ class StoreWrapper implements IStoreWrapper { onTaskRejected?: (task: ITask, reason: string) => void; onTaskAssigned?: (task: ITask) => void; onTaskSelected?: (task: ITask, isClicked: boolean) => void; + onErrorCallback?: (widgetName: string, error: Error) => void; constructor() { this.store = Store.getInstance(); @@ -223,6 +224,24 @@ class StoreWrapper implements IStoreWrapper { }); }; + setOnError = (callback: (widgetName: string, error: Error) => void) => { + this.onErrorCallback = (widgetName: string, error: Error) => { + // @ts-expect-error - test error boundary + this.store.cc.webex.internal.newMetrics.submitBehavioralEvent({ + product: 'wxcc-widgets', + agent: 'browser', + target: 'browser', + verb: 'error', + payload: { + widgets: widgetName, + name: error.name, + message: error.message, + }, + }); + callback(widgetName, error); + }; + }; + refreshTaskList = (): void => { runInAction(() => { this.store.taskList = this.store.cc.taskManager.getAllTasks(); diff --git a/packages/contact-center/task/package.json b/packages/contact-center/task/package.json index 4038cc770..f8bfd322d 100644 --- a/packages/contact-center/task/package.json +++ b/packages/contact-center/task/package.json @@ -23,7 +23,8 @@ "dependencies": { "@webex/cc-components": "workspace:*", "@webex/cc-store": "workspace:*", - "mobx-react-lite": "^4.1.0" + "mobx-react-lite": "^4.1.0", + "react-error-boundary": "^6.0.0" }, "devDependencies": { "@babel/core": "7.25.2", @@ -64,4 +65,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 037dfdef0..03c8ddfa5 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -1,12 +1,13 @@ import React from 'react'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlComponent} from '@webex/cc-components'; -const CallControl: React.FunctionComponent = observer( +const CallControlInternal: React.FunctionComponent = observer( ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute}) => { const { logger, @@ -53,4 +54,17 @@ const CallControl: React.FunctionComponent = observer( } ); +const CallControl: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('CallControl', error); + }} + > + + + ); +}; + export {CallControl}; diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index 25fdf0137..d4ea6b8bb 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -1,12 +1,13 @@ import React from 'react'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlCADComponent} from '@webex/cc-components'; -const CallControlCAD: React.FunctionComponent = observer( +const CallControlCADInternal: React.FunctionComponent = observer( ({ onHoldResume, onEnd, @@ -62,4 +63,17 @@ const CallControlCAD: React.FunctionComponent = observer( } ); +const CallControlCAD: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('CallControlCAD', error); + }} + > + + + ); +}; + export {CallControlCAD}; diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index 6dfe5d4db..146686e71 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -1,21 +1,37 @@ import React from 'react'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useIncomingTask} from '../helper'; import {IncomingTaskComponent} from '@webex/cc-components'; import {IncomingTaskProps} from '../task.types'; -const IncomingTask: React.FunctionComponent = observer(({incomingTask, onAccepted, onRejected}) => { - const {deviceType, logger} = store; - const result = useIncomingTask({incomingTask, onAccepted, onRejected, deviceType, logger}); +const IncomingTaskInternal: React.FunctionComponent = observer( + ({incomingTask, onAccepted, onRejected}) => { + const {deviceType, logger} = store; + const result = useIncomingTask({incomingTask, onAccepted, onRejected, deviceType, logger}); - const props = { - ...result, - logger, - }; + const props = { + ...result, + logger, + }; - return ; -}); + return ; + } +); + +const IncomingTask: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('IncomingTask', error); + }} + > + + + ); +}; export {IncomingTask}; diff --git a/packages/contact-center/task/src/OutdialCall/index.tsx b/packages/contact-center/task/src/OutdialCall/index.tsx index aa6eff781..9f554c293 100644 --- a/packages/contact-center/task/src/OutdialCall/index.tsx +++ b/packages/contact-center/task/src/OutdialCall/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import {OutdialCallComponent} from '@webex/cc-components'; import {useOutdialCall} from '../helper'; -const OutdialCall: React.FunctionComponent = observer(() => { +const OutdialCallInternal: React.FunctionComponent = observer(() => { const {cc, logger} = store; const result = useOutdialCall({cc, logger}); @@ -15,4 +16,17 @@ const OutdialCall: React.FunctionComponent = observer(() => { return ; }); +const OutdialCall: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('OutdialCall', error); + }} + > + + + ); +}; + export {OutdialCall}; diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx index 6d3ed878d..999c317ac 100644 --- a/packages/contact-center/task/src/TaskList/index.tsx +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -1,12 +1,13 @@ import React from 'react'; import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import {TaskListComponent} from '@webex/cc-components'; import {useTaskList} from '../helper'; import {TaskListProps} from '../task.types'; -const TaskList: React.FunctionComponent = observer( +const TaskListInternal: React.FunctionComponent = observer( ({onTaskAccepted, onTaskDeclined, onTaskSelected}) => { const {cc, taskList, currentTask, deviceType, logger} = store; @@ -21,4 +22,17 @@ const TaskList: React.FunctionComponent = observer( } ); +const TaskList: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('TaskList', error); + }} + > + + + ); +}; + export {TaskList}; diff --git a/packages/contact-center/task/src/Utils/task-util.ts b/packages/contact-center/task/src/Utils/task-util.ts index 7883fc931..95dea2710 100644 --- a/packages/contact-center/task/src/Utils/task-util.ts +++ b/packages/contact-center/task/src/Utils/task-util.ts @@ -1,3 +1,4 @@ +import {ILogger} from '@webex/cc-store'; import {ITask} from '@webex/contact-center'; /** @@ -5,49 +6,84 @@ import {ITask} from '@webex/contact-center'; * @param task The task object * @returns An object containing the visibility of various controls based on the task's data */ -export function getControlsVisibility(deviceType: string, featureFlags: {[key: string]: boolean}, task: ITask) { - const {mediaType} = task?.data?.interaction || {}; - - const isCall = mediaType === 'telephony'; - const isChat = mediaType === 'chat'; - const isEmail = mediaType === 'email'; - - const isBrowser = deviceType === 'BROWSER'; - const isAgentDN = deviceType === 'AGENT_DN'; - const isExtension = deviceType === 'EXTENSION'; - - const {isEndCallEnabled, isEndConsultEnabled, webRtcEnabled} = featureFlags; - - const controls = { - accept: - (isBrowser && ((webRtcEnabled && isCall) || isChat || isEmail)) || - (isAgentDN && (isChat || isEmail)) || - (isExtension && (isChat || isEmail)), - decline: isBrowser && webRtcEnabled && isCall, - end: isBrowser || (isEndCallEnabled && isCall) || !isCall, - muteUnmute: isBrowser && webRtcEnabled && isCall, - - holdResume: isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), // Applicable for all type of station login - consult: isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), // Applicable for all type of station login - transfer: isBrowser ? webRtcEnabled : true, // Applicable for all type of station login and media type - - conference: (isBrowser && isCall && webRtcEnabled) || isChat, // This needs further testing after we add support - wrapup: task?.data?.wrapUpRequired ?? false, // Applicable for all type of station login and media type and getting actual value from task data - pauseResumeRecording: isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), // Getting feature flag (isRecordingManagementEnabled) value as undefined, need further testing - endConsult: isEndConsultEnabled && isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), - recordingIndicator: isCall, - }; - - return controls; +export function getControlsVisibility( + deviceType: string, + featureFlags: {[key: string]: boolean}, + task: ITask, + logger?: ILogger +) { + try { + const {mediaType} = task?.data?.interaction || {}; + + const isCall = mediaType === 'telephony'; + const isChat = mediaType === 'chat'; + const isEmail = mediaType === 'email'; + + const isBrowser = deviceType === 'BROWSER'; + const isAgentDN = deviceType === 'AGENT_DN'; + const isExtension = deviceType === 'EXTENSION'; + + const {isEndCallEnabled, isEndConsultEnabled, webRtcEnabled} = featureFlags; + + const controls = { + accept: + (isBrowser && ((webRtcEnabled && isCall) || isChat || isEmail)) || + (isAgentDN && (isChat || isEmail)) || + (isExtension && (isChat || isEmail)), + decline: isBrowser && webRtcEnabled && isCall, + end: isBrowser || (isEndCallEnabled && isCall) || !isCall, + muteUnmute: isBrowser && webRtcEnabled && isCall, + + holdResume: isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), // Applicable for all type of station login + consult: isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), // Applicable for all type of station login + transfer: isBrowser ? webRtcEnabled : true, // Applicable for all type of station login and media type + + conference: (isBrowser && isCall && webRtcEnabled) || isChat, // This needs further testing after we add support + wrapup: task?.data?.wrapUpRequired ?? false, // Applicable for all type of station login and media type and getting actual value from task data + pauseResumeRecording: isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), // Getting feature flag (isRecordingManagementEnabled) value as undefined, need further testing + endConsult: isEndConsultEnabled && isCall && ((isBrowser && webRtcEnabled) || isAgentDN || isExtension), + recordingIndicator: isCall, + }; + + return controls; + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in getControlsVisibility - ${error.message}`, { + module: 'task-util', + method: 'getControlsVisibility', + }); + // Return safe default controls + return { + accept: false, + decline: false, + end: false, + muteUnmute: false, + holdResume: false, + consult: false, + transfer: false, + conference: false, + wrapup: false, + pauseResumeRecording: false, + endConsult: false, + recordingIndicator: false, + }; + } } //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 -export function findHoldTimestamp(interaction: Interaction, mType = 'mainCall'): number | null { - if (!interaction?.media) return null; - for (const key in interaction.media) { - if (interaction.media[key].mType === mType) { - return interaction.media[key].holdTimestamp ?? null; +export function findHoldTimestamp(interaction: Interaction, mType = 'mainCall', logger?: ILogger): number | null { + try { + if (!interaction?.media) return null; + for (const key in interaction.media) { + if (interaction.media[key].mType === mType) { + return interaction.media[key].holdTimestamp ?? null; + } } + return null; + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in findHoldTimestamp - ${error.message}`, { + module: 'task-util', + method: 'findHoldTimestamp', + }); + return null; } - return null; } diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 6d985d17b..5b0743640 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -20,58 +20,107 @@ export const useTaskList = (props: UseTaskListProps) => { }; useEffect(() => { - if (onTaskAccepted) { - store.setTaskAssigned(function (task) { - logger.log(`CC-Widgets: taskAssigned event for ${task.data.interactionId}`, { - module: 'useTaskList', - method: 'setTaskAssigned', + try { + if (onTaskAccepted) { + store.setTaskAssigned(function (task) { + try { + logger.log(`CC-Widgets: taskAssigned event for ${task.data.interactionId}`, { + module: 'useTaskList', + method: 'setTaskAssigned', + }); + onTaskAccepted(task); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in taskAssigned callback - ${error.message}`, { + module: 'useTaskList', + method: 'setTaskAssigned', + }); + } }); - onTaskAccepted(task); - }); - } + } - if (onTaskDeclined) { - store.setTaskRejected(function (task, reason) { - logger.log(`CC-Widgets: taskRejected event for ${task.data.interactionId}`, { - module: 'useTaskList', - method: 'setTaskRejected', + if (onTaskDeclined) { + store.setTaskRejected(function (task, reason) { + try { + logger.log(`CC-Widgets: taskRejected event for ${task.data.interactionId}`, { + module: 'useTaskList', + method: 'setTaskRejected', + }); + onTaskDeclined(task, reason); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in taskRejected callback - ${error.message}`, { + module: 'useTaskList', + method: 'setTaskRejected', + }); + } }); - onTaskDeclined(task, reason); - }); - } + } - if (onTaskSelected) { - store.setTaskSelected(function (task: ITask, isClicked: boolean) { - onTaskSelected({task, isClicked}); + if (onTaskSelected) { + store.setTaskSelected(function (task: ITask, isClicked: boolean) { + try { + onTaskSelected({task, isClicked}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in taskSelected callback - ${error.message}`, { + module: 'useTaskList', + method: 'setTaskSelected', + }); + } + }); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in useTaskList useEffect - ${error.message}`, { + module: 'useTaskList', + method: 'useEffect', }); } }, []); const acceptTask = (task: ITask) => { - logger.info(`CC-Widgets: acceptTask called for ${task.data.interactionId}`, { - module: 'useTaskList', - method: 'acceptTask', - }); - task.accept().catch((error) => { - logError(`CC-Widgets: Error accepting task: ${error}`, 'acceptTask'); - }); + try { + logger.info(`CC-Widgets: acceptTask called for ${task.data.interactionId}`, { + module: 'useTaskList', + method: 'acceptTask', + }); + task.accept().catch((error) => { + logError(`CC-Widgets: Error accepting task: ${error}`, 'acceptTask'); + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in acceptTask - ${error.message}`, { + module: 'useTaskList', + method: 'acceptTask', + }); + } }; const declineTask = (task: ITask) => { - logger.info(`CC-Widgets: declineTask called for ${task.data.interactionId}`, { - module: 'useTaskList', - method: 'declineTask', - }); - task.decline().catch((error) => { - logError(`CC-Widgets: Error declining task: ${error}`, 'declineTask'); - }); - logger.log(`CC-Widgets: incoming task declined for ${task.data.interactionId}`, { - module: 'useTaskList', - method: 'declineTask', - }); + try { + logger.info(`CC-Widgets: declineTask called for ${task.data.interactionId}`, { + module: 'useTaskList', + method: 'declineTask', + }); + task.decline().catch((error) => { + logError(`CC-Widgets: Error declining task: ${error}`, 'declineTask'); + }); + logger.log(`CC-Widgets: incoming task declined for ${task.data.interactionId}`, { + module: 'useTaskList', + method: 'declineTask', + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in declineTask - ${error.message}`, { + module: 'useTaskList', + method: 'declineTask', + }); + } }; const onTaskSelect = (task: ITask) => { - store.setCurrentTask(task, true); + try { + store.setCurrentTask(task, true); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in onTaskSelect - ${error.message}`, { + module: 'useTaskList', + method: 'onTaskSelect', + }); + } }; return {taskList, acceptTask, declineTask, onTaskSelect, isBrowser}; @@ -82,33 +131,73 @@ export const useIncomingTask = (props: UseTaskProps) => { const isBrowser = deviceType === 'BROWSER'; const taskAssignCallback = () => { - if (onAccepted) onAccepted({task: incomingTask}); + try { + if (onAccepted) onAccepted({task: incomingTask}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in taskAssignCallback - ${error.message}`, { + module: 'useIncomingTask', + method: 'taskAssignCallback', + }); + } }; const taskRejectCallback = () => { - if (onRejected) onRejected({task: incomingTask}); + try { + if (onRejected) onRejected({task: incomingTask}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in taskRejectCallback - ${error.message}`, { + module: 'useIncomingTask', + method: 'taskRejectCallback', + }); + } }; useEffect(() => { - if (!incomingTask) return; - store.setTaskCallback( - TASK_EVENTS.TASK_ASSIGNED, - () => { - if (onAccepted) onAccepted({task: incomingTask}); - }, - incomingTask.data.interactionId - ); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - return () => { - store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - }; + try { + if (!incomingTask) return; + store.setTaskCallback( + TASK_EVENTS.TASK_ASSIGNED, + () => { + try { + if (onAccepted) onAccepted({task: incomingTask}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in TASK_ASSIGNED callback - ${error.message}`, { + module: 'useIncomingTask', + method: 'TASK_ASSIGNED_callback', + }); + } + }, + incomingTask.data.interactionId + ); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); + + return () => { + try { + store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask?.data.interactionId); + store.removeTaskCallback( + TASK_EVENTS.TASK_CONSULT_ACCEPTED, + taskAssignCallback, + incomingTask?.data.interactionId + ); + store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in useIncomingTask cleanup - ${error.message}`, { + module: 'useIncomingTask', + method: 'useEffect_cleanup', + }); + } + }; + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in useIncomingTask useEffect - ${error.message}`, { + module: 'useIncomingTask', + method: 'useEffect', + }); + } }, [incomingTask]); const logError = (message: string, method: string) => { @@ -119,33 +208,47 @@ export const useIncomingTask = (props: UseTaskProps) => { }; const accept = () => { - logger.info(`CC-Widgets: incomingTask.accept() called`, { - module: 'useIncomingTask', - method: 'accept', - }); - if (!incomingTask?.data.interactionId) return; - incomingTask.accept().catch((error) => { - logError(`CC-Widgets: Error accepting incoming task: ${error}`, 'accept'); - }); - logger.log(`CC-Widgets: incomingTask accepted`, { - module: 'useIncomingTask', - method: 'accept', - }); + try { + logger.info(`CC-Widgets: incomingTask.accept() called`, { + module: 'useIncomingTask', + method: 'accept', + }); + if (!incomingTask?.data.interactionId) return; + incomingTask.accept().catch((error) => { + logError(`CC-Widgets: Error accepting incoming task: ${error}`, 'accept'); + }); + logger.log(`CC-Widgets: incomingTask accepted`, { + module: 'useIncomingTask', + method: 'accept', + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in accept - ${error.message}`, { + module: 'useIncomingTask', + method: 'accept', + }); + } }; const reject = () => { - logger.info(`CC-Widgets: incomingTask.reject() called`, { - module: 'useIncomingTask', - method: 'reject', - }); - if (!incomingTask?.data.interactionId) return; - incomingTask.decline().catch((error) => { - logError(`CC-Widgets: Error rejecting incoming task: ${error}`, 'reject'); - }); - logger.log(`CC-Widgets: incomingTask rejected`, { - module: 'useIncomingTask', - method: 'reject', - }); + try { + logger.info(`CC-Widgets: incomingTask.reject() called`, { + module: 'useIncomingTask', + method: 'reject', + }); + if (!incomingTask?.data.interactionId) return; + incomingTask.decline().catch((error) => { + logError(`CC-Widgets: Error rejecting incoming task: ${error}`, 'reject'); + }); + logger.log(`CC-Widgets: incomingTask rejected`, { + module: 'useIncomingTask', + method: 'reject', + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in reject - ${error.message}`, { + module: 'useIncomingTask', + method: 'reject', + }); + } }; return { @@ -214,7 +317,7 @@ export const useCallControl = (props: useCallControlProps) => { // Get holdTimestamp from the interaction object const holdTimestamp = currentTask?.data?.interaction - ? findHoldTimestamp(currentTask.data.interaction, 'mainCall') + ? findHoldTimestamp(currentTask.data.interaction, 'mainCall', logger) : null; if (holdTimestamp) { @@ -247,26 +350,34 @@ export const useCallControl = (props: useCallControlProps) => { }, [currentTask?.data?.interaction]); // Function to extract consulting agent information const extractConsultingAgent = useCallback(() => { - if (!currentTask?.data?.interaction?.participants) return; + try { + if (!currentTask?.data?.interaction?.participants) return; - const {interaction} = currentTask.data; - const myAgentId = store.cc.agentConfig?.agentId; + const {interaction} = currentTask.data; + const myAgentId = store.cc.agentConfig?.agentId; - // Find all agent participants except the current agent - const otherAgents = Object.values(interaction.participants || {}).filter( - (participant): participant is Participant => - (participant as Participant).pType === 'Agent' && (participant as Participant).id !== myAgentId - ); + // Find all agent participants except the current agent + const otherAgents = Object.values(interaction.participants || {}).filter( + (participant): participant is Participant => + (participant as Participant).pType === 'Agent' && (participant as Participant).id !== myAgentId + ); - // Pick the first other agent (should only be one in a consult) - const foundAgent = otherAgents.length > 0 ? {id: otherAgents[0].id, name: otherAgents[0].name} : null; + // Pick the first other agent (should only be one in a consult) + const foundAgent = otherAgents.length > 0 ? {id: otherAgents[0].id, name: otherAgents[0].name} : null; - if (foundAgent) { - setConsultAgentName(foundAgent.name); - setConsultAgentId(foundAgent.id); - logger.info(`Consulting agent detected: ${foundAgent.name} ${foundAgent.id}`, { - module: 'widget-cc-task#helper.ts', - method: 'useCallControl#extractConsultingAgent', + if (foundAgent) { + setConsultAgentName(foundAgent.name); + setConsultAgentId(foundAgent.id); + logger.info(`Consulting agent detected: ${foundAgent.name} ${foundAgent.id}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#extractConsultingAgent', + }); + } + } catch (error) { + console.log('error', error); + logger.error(`CC-Widgets: Task: Error in extractConsultingAgent - ${error.message}`, { + module: 'useCallControl', + method: 'extractConsultingAgent', }); } }, [currentTask, logger, consultInitiated]); @@ -289,7 +400,10 @@ export const useCallControl = (props: useCallControlProps) => { logger.info(`Loaded ${agents.length} buddy agents`, {module: 'helper.ts', method: 'loadBuddyAgents'}); setBuddyAgents(agents); } catch (error) { - logger.error(`Error loading buddy agents: ${error}`, {module: 'helper.ts', method: 'loadBuddyAgents'}); + logger?.error(`CC-Widgets: Task: Error loading buddy agents - ${error.message || error}`, { + module: 'useCallControl', + method: 'loadBuddyAgents', + }); setBuddyAgents([]); } }, [logger]); @@ -299,63 +413,108 @@ export const useCallControl = (props: useCallControlProps) => { const queues = await store.getQueues(); setQueues(queues); } catch (error) { - logError(`Error loading queues: ${error}`, 'loadQueues'); + logger?.error(`CC-Widgets: Task: Error loading queues - ${error.message || error}`, { + module: 'useCallControl', + method: 'loadQueues', + }); setQueues([]); } }, [logger]); const holdCallback = () => { - setIsHeld(true); - if (onHoldResume) { - onHoldResume({ - isHeld: true, - task: currentTask, + try { + setIsHeld(true); + if (onHoldResume) { + onHoldResume({ + isHeld: true, + task: currentTask, + }); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in holdCallback - ${error.message}`, { + module: 'useCallControl', + method: 'holdCallback', }); } }; const resumeCallback = () => { - setIsHeld(false); - if (onHoldResume) { - onHoldResume({ - isHeld: false, - task: currentTask, + try { + setIsHeld(false); + if (onHoldResume) { + onHoldResume({ + isHeld: false, + task: currentTask, + }); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in resumeCallback - ${error.message}`, { + module: 'useCallControl', + method: 'resumeCallback', }); } }; const endCallCallback = () => { - if (onEnd) { - onEnd({ - task: currentTask, + try { + if (onEnd) { + onEnd({ + task: currentTask, + }); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in endCallCallback - ${error.message}`, { + module: 'useCallControl', + method: 'endCallCallback', }); } }; const wrapupCallCallback = ({wrapUpAuxCodeId}) => { - const wrapUpReason = store.wrapupCodes.find((code) => code.id === wrapUpAuxCodeId)?.name; - if (onWrapUp) { - onWrapUp({ - task: currentTask, - wrapUpReason: wrapUpReason, + try { + const wrapUpReason = store.wrapupCodes.find((code) => code.id === wrapUpAuxCodeId)?.name; + if (onWrapUp) { + onWrapUp({ + task: currentTask, + wrapUpReason: wrapUpReason, + }); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in wrapupCallCallback - ${error.message}`, { + module: 'useCallControl', + method: 'wrapupCallCallback', }); } }; const pauseRecordingCallback = () => { - setIsRecording(false); - onRecordingToggle({ - isRecording: false, - task: currentTask, - }); + try { + setIsRecording(false); + onRecordingToggle({ + isRecording: false, + task: currentTask, + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in pauseRecordingCallback - ${error.message}`, { + module: 'useCallControl', + method: 'pauseRecordingCallback', + }); + } }; const resumeRecordingCallback = () => { - setIsRecording(true); - onRecordingToggle({ - isRecording: true, - task: currentTask, - }); + try { + setIsRecording(true); + onRecordingToggle({ + isRecording: true, + task: currentTask, + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in resumeRecordingCallback - ${error.message}`, { + module: 'useCallControl', + method: 'resumeRecordingCallback', + }); + } }; useEffect(() => { @@ -403,90 +562,127 @@ export const useCallControl = (props: useCallControlProps) => { }; const toggleHold = (hold: boolean) => { - logger.info(`toggleHold(${hold}) called`, {module: 'useCallControl', method: 'toggleHold'}); - if (hold) { - currentTask - .hold() - .catch((e) => logger.error(`Hold failed: ${e}`, {module: 'useCallControl', method: 'toggleHold'})); - } else { - currentTask - .resume() - .catch((e) => logger.error(`Resume failed: ${e}`, {module: 'useCallControl', method: 'toggleHold'})); + try { + logger.info(`toggleHold(${hold}) called`, {module: 'useCallControl', method: 'toggleHold'}); + if (hold) { + currentTask + .hold() + .catch((e) => logger.error(`Hold failed: ${e}`, {module: 'useCallControl', method: 'toggleHold'})); + } else { + currentTask + .resume() + .catch((e) => logger.error(`Resume failed: ${e}`, {module: 'useCallControl', method: 'toggleHold'})); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in toggleHold - ${error.message}`, { + module: 'useCallControl', + method: 'toggleHold', + }); } }; const toggleRecording = () => { - if (isRecording) { - currentTask.pauseRecording().catch((error: Error) => { - logError(`Error pausing recording: ${error}`, 'toggleRecording'); - }); - } else { - currentTask.resumeRecording({autoResumed: false}).catch((error: Error) => { - logError(`Error resuming recording: ${error}`, 'toggleRecording'); + try { + if (isRecording) { + currentTask.pauseRecording().catch((error: Error) => { + logError(`Error pausing recording: ${error}`, 'toggleRecording'); + }); + } else { + currentTask.resumeRecording({autoResumed: false}).catch((error: Error) => { + logError(`Error resuming recording: ${error}`, 'toggleRecording'); + }); + } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in toggleRecording - ${error.message}`, { + module: 'useCallControl', + method: 'toggleRecording', }); } }; const toggleMute = async () => { - console.log('Mute control not available', controlVisibility); - if (!controlVisibility?.muteUnmute) { - logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); - return; - } + try { + console.log('Mute control not available', controlVisibility); + if (!controlVisibility?.muteUnmute) { + logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); + return; + } - logger.info('toggleMute() called', {module: 'useCallControl', method: 'toggleMute'}); + logger.info('toggleMute() called', {module: 'useCallControl', method: 'toggleMute'}); - // Store the intended new state - const intendedMuteState = !isMuted; + // Store the intended new state + const intendedMuteState = !isMuted; - try { - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - await currentTask.toggleMute(); + try { + //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 + await currentTask.toggleMute(); - // Only update state after successful SDK call - store.setIsMuted(intendedMuteState); + // Only update state after successful SDK call + store.setIsMuted(intendedMuteState); - if (onToggleMute) { - onToggleMute({ - isMuted: intendedMuteState, - task: currentTask, - }); - } + if (onToggleMute) { + onToggleMute({ + isMuted: intendedMuteState, + task: currentTask, + }); + } - logger.info(`Mute state toggled to: ${intendedMuteState}`, {module: 'useCallControl', method: 'toggleMute'}); - } catch (error) { - logger.error(`toggleMute failed: ${error}`, {module: 'useCallControl', method: 'toggleMute'}); + logger.info(`Mute state toggled to: ${intendedMuteState}`, {module: 'useCallControl', method: 'toggleMute'}); + } catch (error) { + logger.error(`toggleMute failed: ${error}`, {module: 'useCallControl', method: 'toggleMute'}); - if (onToggleMute) { - onToggleMute({ - isMuted: isMuted, - task: currentTask, - }); + if (onToggleMute) { + onToggleMute({ + isMuted: isMuted, + task: currentTask, + }); + } } + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in toggleMute - ${error.message}`, { + module: 'useCallControl', + method: 'toggleMute', + }); } }; const endCall = () => { - logger.info('endCall() called', {module: 'useCallControl', method: 'endCall'}); - currentTask.end().catch((e) => logger.error(`endCall failed: ${e}`, {module: 'useCallControl', method: 'endCall'})); + try { + logger.info('endCall() called', {module: 'useCallControl', method: 'endCall'}); + currentTask + .end() + .catch((e) => logger.error(`endCall failed: ${e}`, {module: 'useCallControl', method: 'endCall'})); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in endCall - ${error.message}`, { + module: 'useCallControl', + method: 'endCall', + }); + } }; const wrapupCall = (wrapUpReason: string, auxCodeId: string) => { - currentTask - .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) - .then(() => { - const taskKeys = Object.keys(store.taskList); - if (taskKeys.length > 0) { - store.setCurrentTask(store.taskList[taskKeys[0]]); - store.setState({ - developerName: ENGAGED_LABEL, - name: ENGAGED_USERNAME, - }); - } - }) - .catch((error: Error) => { - logError(`Error wrapping up call: ${error}`, 'wrapupCall'); + try { + currentTask + .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) + .then(() => { + const taskKeys = Object.keys(store.taskList); + if (taskKeys.length > 0) { + store.setCurrentTask(store.taskList[taskKeys[0]]); + store.setState({ + developerName: ENGAGED_LABEL, + name: ENGAGED_USERNAME, + }); + } + }) + .catch((error: Error) => { + logError(`Error wrapping up call: ${error}`, 'wrapupCall'); + }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in wrapupCall - ${error.message}`, { + module: 'useCallControl', + method: 'wrapupCall', }); + } }; const transferCall = async (to: string, type: DestinationType) => { @@ -572,8 +768,8 @@ export const useCallControl = (props: useCallControlProps) => { }; const controlVisibility = useMemo( - () => getControlsVisibility(deviceType, featureFlags, currentTask), - [deviceType, featureFlags, currentTask] + () => getControlsVisibility(deviceType, featureFlags, currentTask, logger), + [deviceType, featureFlags, currentTask, logger] ); // Add useEffect for auto wrap-up timer @@ -651,22 +847,29 @@ export const useOutdialCall = (props: useOutdialCallProps) => { const {cc, logger} = props; const startOutdial = (destination: string) => { - // Perform validation on destination number. - if (!destination || !destination.trim()) { - alert('Destination number is required, it cannot be empty'); - return; - } - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - cc.startOutdial(destination) - .then((response) => { - logger.info('Outdial call started', response); - }) - .catch((error: Error) => { - logger.error(`${error}`, { - module: 'widget-OutdialCall#helper.ts', - method: 'startOutdial', + try { + // Perform validation on destination number. + if (!destination || !destination.trim()) { + alert('Destination number is required, it cannot be empty'); + return; + } + //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 + cc.startOutdial(destination) + .then((response) => { + logger.info('Outdial call started', response); + }) + .catch((error: Error) => { + logger.error(`${error}`, { + module: 'widget-OutdialCall#helper.ts', + method: 'startOutdial', + }); }); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error in startOutdial - ${error.message}`, { + module: 'useOutdialCall', + method: 'startOutdial', }); + } }; return { diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index c89af5998..afd21a7b1 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -12,6 +12,16 @@ const onWrapUpCb = jest.fn(); const onRecordingToggleCb = jest.fn(); describe('CallControl Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error for error boundary tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders CallControlPresentational with correct props', () => { const useCallControlSpy = jest.spyOn(helper, 'useCallControl').mockReturnValue({ currentTask: mockTask, @@ -83,4 +93,29 @@ describe('CallControl Component', () => { onToggleMute: undefined, }); }); + + describe('ErrorBoundary Tests', () => { + it('should render empty fragment when ErrorBoundary catches an error', () => { + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + + // Mock the useCallControl to throw an error + jest.spyOn(helper, 'useCallControl').mockImplementation(() => { + throw new Error('Test error in useCallControl'); + }); + + const {container} = render( + + ); + + // The fallback should render an empty fragment (no content) + expect(container.firstChild).toBeNull(); + expect(mockOnErrorCallback).toHaveBeenCalledWith('CallControl', Error('Test error in useCallControl')); + }); + }); }); diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx index 7b0fdc862..4658367e7 100644 --- a/packages/contact-center/task/tests/IncomingTask/index.tsx +++ b/packages/contact-center/task/tests/IncomingTask/index.tsx @@ -17,6 +17,15 @@ const onAcceptedCb = jest.fn(); const onRejectedCb = jest.fn(); describe('IncomingTask Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error for error boundary tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders IncomingTaskPresentational with correct props', () => { const useIncomingTaskSpy = jest.spyOn(helper, 'useIncomingTask'); @@ -38,4 +47,21 @@ describe('IncomingTask Component', () => { onRejected: onRejectedCb, }); }); + + describe('ErrorBoundary Tests', () => { + it('should render empty fragment when ErrorBoundary catches an error', () => { + jest.spyOn(helper, 'useIncomingTask').mockImplementation(() => { + throw new Error('Test error in useIncomingTask'); + }); + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + const {container} = render( + + ); + + expect(container.firstChild).toBeNull(); + expect(mockOnErrorCallback).toHaveBeenCalledWith('IncomingTask', Error('Test error in useIncomingTask')); + expect(mockOnErrorCallback).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/contact-center/task/tests/OutdialCall/index.tsx b/packages/contact-center/task/tests/OutdialCall/index.tsx index ab415bcbf..4db942fbe 100644 --- a/packages/contact-center/task/tests/OutdialCall/index.tsx +++ b/packages/contact-center/task/tests/OutdialCall/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {render} from '@testing-library/react'; import * as helper from '../../src/helper'; import {OutdialCall} from '../../src/OutdialCall'; +import store from '@webex/cc-store'; // Mock dependencies jest.mock('@webex/cc-store', () => ({ @@ -12,9 +13,20 @@ jest.mock('@webex/cc-store', () => ({ error: jest.fn(), warn: jest.fn(), }, + onErrorCallback: jest.fn(), })); describe('OutdialCall Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error for error boundary tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('render OutdialCallComponent with correct props', () => { const useOutdialCallSpy = jest.spyOn(helper, 'useOutdialCall'); render(); @@ -29,4 +41,32 @@ describe('OutdialCall Component', () => { }, }); }); + + describe('ErrorBoundary Tests', () => { + it('should render empty fragment when ErrorBoundary catches an error and call the callback', () => { + // Mock the useOutdialCall to throw an error + jest.spyOn(helper, 'useOutdialCall').mockImplementation(() => { + throw new Error('Test error in useOutdialCall'); + }); + + const {container} = render(); + + // The fallback should render an empty fragment (no content) + expect(container.firstChild).toBeNull(); + expect(store.onErrorCallback).toHaveBeenCalledWith('OutdialCall', Error('Test error in useOutdialCall')); + }); + it('should render empty fragment when ErrorBoundary catches an error and call the callback', () => { + // Mock the useOutdialCall to throw an error + jest.spyOn(helper, 'useOutdialCall').mockImplementation(() => { + throw new Error('Test error in useOutdialCall'); + }); + store.onErrorCallback = undefined; + + const {container} = render(); + + // The fallback should render an empty fragment (no content) + expect(container.firstChild).toBeNull(); + expect(store.onErrorCallback).toBeUndefined(); + }); + }); }); diff --git a/packages/contact-center/task/tests/TaskList/index.tsx b/packages/contact-center/task/tests/TaskList/index.tsx index 18326e80b..78d7fd71b 100644 --- a/packages/contact-center/task/tests/TaskList/index.tsx +++ b/packages/contact-center/task/tests/TaskList/index.tsx @@ -31,7 +31,17 @@ jest.mock('@webex/cc-store', () => ({ describe('TaskList Component', () => { const helperSpy = jest.spyOn(helper, 'useTaskList'); - afterEach(cleanup); + + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error for error boundary tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); it('renders TaskListPresentational with the correct props', () => { render(); @@ -51,4 +61,24 @@ describe('TaskList Component', () => { taskList: taskListMock, }); }); + + describe('ErrorBoundary Tests', () => { + it('should render empty fragment when ErrorBoundary catches an error', () => { + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + // Mock the useTaskList to throw an error + jest.spyOn(helper, 'useTaskList').mockImplementation(() => { + throw new Error('Test error in useTaskList'); + }); + + const {container} = render( + + ); + + // The fallback should render an empty fragment (no content) + expect(container.firstChild).toBeNull(); + expect(mockOnErrorCallback).toHaveBeenCalledWith('TaskList', expect.any(Error)); + expect(mockOnErrorCallback).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 5749c4c1b..14ec2f7c7 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -323,6 +323,71 @@ describe('useIncomingTask Hook', () => { method: 'useIncomingTask#reject', }); }); + describe('useIncomingTask Error Handling', () => { + const onRejected = jest.fn(); + it('should handle errors in taskAssignCallback', () => { + const errorOnAccepted = jest.fn().mockImplementation(() => { + throw new Error('Test error in onAccepted'); + }); + + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + renderHook(() => + useIncomingTask({ + onAccepted: errorOnAccepted, + onRejected, + deviceType: 'BROWSER', + incomingTask: taskMock, + logger, + }) + ); + + // Find the taskAssignCallback + const taskAssignCallback = setTaskCallbackSpy.mock.calls.find( + (call) => call[0] === TASK_EVENTS.TASK_ASSIGNED + )?.[1]; + + act(() => { + taskAssignCallback(); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in TASK_ASSIGNED callback - Test error in onAccepted', + { + module: 'useIncomingTask', + method: 'TASK_ASSIGNED_callback', + } + ); + }); + + it('should handle errors in accept method', () => { + const mockErrorTask = { + ...taskMock, + accept: jest.fn().mockImplementation(() => { + throw new Error('Accept synchronous error'); + }), + }; + + const {result} = renderHook(() => + useIncomingTask({ + onAccepted, + onRejected, + deviceType: 'BROWSER', + incomingTask: mockErrorTask, + logger, + }) + ); + + act(() => { + result.current.accept(); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in accept - Accept synchronous error', { + module: 'useIncomingTask', + method: 'accept', + }); + }); + }); }); describe('useTaskList Hook', () => { @@ -531,6 +596,135 @@ describe('useTaskList Hook', () => { // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); }); + describe('useTaskList Error Handling', () => { + it('should handle errors in taskAssigned callback', () => { + const errorOnTaskAccepted = jest.fn().mockImplementation(() => { + throw new Error('Test error in onTaskAccepted'); + }); + + const setTaskAssignedSpy = jest.spyOn(store, 'setTaskAssigned'); + + renderHook(() => + useTaskList({ + onTaskAccepted: errorOnTaskAccepted, + onTaskDeclined, + onTaskSelected, + logger, + taskList: {}, + deviceType: 'BROWSER', + cc: mockCC, + }) + ); + + // Trigger the callback + const taskAssignedCallback = setTaskAssignedSpy.mock.calls[0][0]; + act(() => { + taskAssignedCallback(taskMock); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in taskAssigned callback - Test error in onTaskAccepted', + { + module: 'useTaskList', + method: 'setTaskAssigned', + } + ); + }); + + it('should handle errors in taskSelected callback', () => { + const errorOnTaskSelected = jest.fn().mockImplementation(() => { + throw new Error('Test error in onTaskSelected'); + }); + + const setTaskSelectedSpy = jest.spyOn(store, 'setTaskSelected'); + + renderHook(() => + useTaskList({ + onTaskAccepted: onTaskAccepted, + onTaskDeclined, + onTaskSelected: errorOnTaskSelected, + logger, + taskList: {}, + deviceType: 'BROWSER', + cc: mockCC, + }) + ); + + // Trigger the callback + const taskSelectedCallback = setTaskSelectedSpy.mock.calls[0][0]; + act(() => { + taskSelectedCallback(taskMock, true); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in taskSelected callback - Test error in onTaskSelected', + { + module: 'useTaskList', + method: 'setTaskSelected', + } + ); + }); + + it('should handle errors in acceptTask', () => { + const mockErrorTask = { + ...taskMock, + accept: jest.fn().mockImplementation(() => { + throw new Error('Task accept error'); + }), + }; + + const {result} = renderHook(() => + useTaskList({ + onTaskAccepted, + onTaskDeclined, + onTaskSelected, + logger, + taskList: {}, + deviceType: 'BROWSER', + cc: mockCC, + }) + ); + + act(() => { + result.current.acceptTask(mockErrorTask); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in acceptTask - Task accept error', { + module: 'useTaskList', + method: 'acceptTask', + }); + }); + + it('should handle errors in onTaskSelect', () => { + const originalSetCurrentTask = store.setCurrentTask; + store.setCurrentTask = jest.fn().mockImplementation(() => { + throw new Error('setCurrentTask error'); + }); + + const {result} = renderHook(() => + useTaskList({ + onTaskAccepted, + onTaskDeclined, + onTaskSelected, + logger, + taskList: {}, + deviceType: 'BROWSER', + cc: mockCC, + }) + ); + + act(() => { + result.current.onTaskSelect(taskMock); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in onTaskSelect - setCurrentTask error', { + module: 'useTaskList', + method: 'onTaskSelect', + }); + + store.setCurrentTask = originalSetCurrentTask; + }); + }); }); describe('useCallControl', () => { @@ -1120,10 +1314,13 @@ describe('useCallControl', () => { await result.current.loadBuddyAgents(); }); expect(result.current.buddyAgents).toEqual([]); - expect(mockLogger.error).toHaveBeenCalledWith('Error loading buddy agents: Error: Buddy agents loading failed', { - module: 'helper.ts', - method: 'loadBuddyAgents', - }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error loading buddy agents - Buddy agents loading failed', + { + module: 'useCallControl', + method: 'loadBuddyAgents', + } + ); getBuddyAgentsSpy.mockRestore(); }); @@ -2261,6 +2458,174 @@ describe('useCallControl', () => { expect(mockCurrentTask.toggleMute).not.toHaveBeenCalled(); }); }); + + describe('useCallControl Error Handling', () => { + const onHoldResume = jest.fn(); + const onEnd = jest.fn(); + const onWrapUp = jest.fn(); + const onRecordingToggle = jest.fn(); + const onToggleMute = jest.fn(); + const logger = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + log: jest.fn(), + trace: jest.fn(), + }; + + it('should handle errors in extractConsultingAgent', () => { + // Mock currentTask with problematic participants structure + jest.spyOn(logger, 'info').mockImplementation(() => { + throw new Error('Participants access error'); + }); + const problematicTask = { + ...taskMock, + data: { + ...taskMock.data, + interaction: { + participants: { + '123': { + pType: 'Agent', + id: '123', + name: 'Agent 1', + }, + }, + }, + }, + }; + + renderHook(() => + useCallControl({ + currentTask: problematicTask, + onHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + logger, + consultInitiated: false, + deviceType: 'BROWSER', + featureFlags: {webRtcEnabled: true}, + isMuted: false, + }) + ); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in extractConsultingAgent - Participants access error', + { + module: 'useCallControl', + method: 'extractConsultingAgent', + } + ); + }); + + it('should handle errors in holdCallback', () => { + const errorOnHoldResume = jest.fn().mockImplementation(() => { + throw new Error('Hold resume callback error'); + }); + + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + renderHook(() => + useCallControl({ + currentTask: taskMock, + onHoldResume: errorOnHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + logger, + consultInitiated: false, + deviceType: 'BROWSER', + featureFlags: {webRtcEnabled: true}, + isMuted: false, + }) + ); + + // Find the hold callback + const holdCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_HOLD)?.[1]; + + act(() => { + holdCallback(); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in holdCallback - Hold resume callback error', + { + module: 'useCallControl', + method: 'holdCallback', + } + ); + }); + + it('should handle errors in toggleHold', () => { + const mockErrorTask = { + ...taskMock, + hold: jest.fn().mockImplementation(() => { + throw new Error('Hold method error'); + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockErrorTask, + onHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + logger, + consultInitiated: false, + deviceType: 'BROWSER', + featureFlags: {webRtcEnabled: true}, + isMuted: false, + }) + ); + + act(() => { + result.current.toggleHold(true); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in toggleHold - Participants access error', { + module: 'useCallControl', + method: 'toggleHold', + }); + }); + + it('should handle errors in loadBuddyAgents', async () => { + const originalGetBuddyAgents = store.getBuddyAgents; + store.getBuddyAgents = jest.fn().mockImplementation(() => { + throw new Error('getBuddyAgents error'); + }); + + const {result} = renderHook(() => + useCallControl({ + currentTask: taskMock, + onHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + logger, + consultInitiated: false, + deviceType: 'BROWSER', + featureFlags: {webRtcEnabled: true}, + isMuted: false, + }) + ); + + await act(async () => { + await result.current.loadBuddyAgents(); + }); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error loading buddy agents - getBuddyAgents error', { + module: 'useCallControl', + method: 'loadBuddyAgents', + }); + + store.getBuddyAgents = originalGetBuddyAgents; + }); + }); }); describe('useOutdialCall', () => { @@ -2363,4 +2728,33 @@ describe('useOutdialCall', () => { expect(ccMock.startOutdial).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); }); + describe('useOutdialCall Error Handling', () => { + it('should handle errors in startOutdial', () => { + const mockErrorCC = { + ...mockCC, + startOutdial: jest.fn().mockImplementation(() => { + throw new Error('startOutdial synchronous error'); + }), + }; + + const {result} = renderHook(() => + useOutdialCall({ + cc: mockErrorCC, + logger, + }) + ); + + act(() => { + result.current.startOutdial('1234567890'); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in startOutdial - startOutdial synchronous error', + { + module: 'useOutdialCall', + method: 'startOutdial', + } + ); + }); + }); }); diff --git a/packages/contact-center/task/tests/utils/task-util.ts b/packages/contact-center/task/tests/utils/task-util.ts index ac4b9375c..3fc45299b 100644 --- a/packages/contact-center/task/tests/utils/task-util.ts +++ b/packages/contact-center/task/tests/utils/task-util.ts @@ -223,6 +223,51 @@ describe('getControlsVisibility', () => { expect(getControlsVisibility(deviceType, featureFlags, task)).toEqual(expectedControls); }); + + it('should handle errors when accessing featureFlags and return safe defaults', () => { + const logger = { + error: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + }; + const deviceType = 'BROWSER'; + // Create problematic featureFlags that throw when accessing properties + const problematicFeatureFlags = new Proxy( + {}, + { + get: () => { + throw new Error('FeatureFlags access error'); + }, + } + ); + + const result = getControlsVisibility(deviceType, problematicFeatureFlags, mockTask, logger); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: Task: Error in getControlsVisibility - FeatureFlags access error', + { + module: 'task-util', + method: 'getControlsVisibility', + } + ); + + expect(result).toEqual({ + accept: false, + decline: false, + end: false, + muteUnmute: false, + holdResume: false, + consult: false, + transfer: false, + conference: false, + wrapup: false, + pauseResumeRecording: false, + endConsult: false, + recordingIndicator: false, + }); + }); }); describe('findHoldTimestamp', () => { @@ -278,4 +323,35 @@ describe('findHoldTimestamp', () => { }; expect(findHoldTimestamp(interaction, 'mainCall')).toBe(42); }); + + it('should handle errors when accessing interaction media and return null', () => { + const logger = { + error: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + }; + // Create a problematic interaction that throws when accessing media + const problematicInteraction = new Proxy( + {}, + { + get: (target, prop) => { + if (prop === 'media') { + throw new Error('Media access error'); + } + return target[prop]; + }, + } + ); + + const result = findHoldTimestamp(problematicInteraction, 'mainCall', logger); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in findHoldTimestamp - Media access error', { + module: 'task-util', + method: 'findHoldTimestamp', + }); + + expect(result).toBeNull(); + }); }); diff --git a/packages/contact-center/ui-logging/package.json b/packages/contact-center/ui-logging/package.json index 1709ac389..489310787 100644 --- a/packages/contact-center/ui-logging/package.json +++ b/packages/contact-center/ui-logging/package.json @@ -36,4 +36,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/user-state/package.json b/packages/contact-center/user-state/package.json index 848052319..0e4bb37ee 100644 --- a/packages/contact-center/user-state/package.json +++ b/packages/contact-center/user-state/package.json @@ -24,6 +24,7 @@ "@webex/cc-components": "workspace:*", "@webex/cc-store": "workspace:*", "mobx-react-lite": "^4.1.0", + "react-error-boundary": "^6.0.0", "typescript": "5.6.3" }, "devDependencies": { @@ -37,6 +38,7 @@ "@testing-library/react": "16.0.1", "@types/jest": "29.5.14", "@types/react-test-renderer": "18", + "@webex/test-fixtures": "workspace:*", "babel-jest": "29.7.0", "babel-loader": "9.2.1", "eslint": "^9.20.1", @@ -62,4 +64,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/user-state/src/helper.ts b/packages/contact-center/user-state/src/helper.ts index 13041579a..63a06e946 100644 --- a/packages/contact-center/user-state/src/helper.ts +++ b/packages/contact-center/user-state/src/helper.ts @@ -21,21 +21,28 @@ export const useUserState = ({ const prevStateRef = useRef(currentState); const callOnStateChange = () => { - logger.info('CC-Widgets: useUserState callOnStateChange(): invoking onStateChange', { - module: 'useUserState', - method: 'callOnStateChange', - }); - if (onStateChange) { - if (customState && 'developerName' in customState && customState?.developerName) { - onStateChange(customState); - return; - } - for (const code of idleCodes) { - if (code.id === currentState) { - onStateChange(code); - break; + try { + logger.info('CC-Widgets: useUserState callOnStateChange(): invoking onStateChange', { + module: 'useUserState', + method: 'callOnStateChange', + }); + if (onStateChange) { + if (customState && 'developerName' in customState && customState?.developerName) { + onStateChange(customState); + return; + } + for (const code of idleCodes) { + if (code.id === currentState) { + onStateChange(code); + break; + } } } + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in callOnStateChange - ${error.message}`, { + module: 'useUserState', + method: 'callOnStateChange', + }); } }; @@ -92,130 +99,188 @@ export const useUserState = ({ `; useEffect(() => { - logger.info(`CC-Widgets: Initializing worker`, { - module: 'useUserState', - method: 'useEffect - initial', - }); - - const blob = new Blob([workerScript], {type: 'application/javascript'}); - const workerUrl = URL.createObjectURL(blob); - workerRef.current = new Worker(workerUrl); - workerRef.current.postMessage({type: 'start', startTime: Date.now()}); - workerRef.current.postMessage({type: 'startIdleCode', startTime: Date.now()}); - workerRef.current.onmessage = (event) => { - if (event.data.type === 'elapsedTime') { - setElapsedTime(event.data.elapsedTime > 0 ? event.data.elapsedTime : 0); - } else if (event.data.type === 'lastIdleStateChangeElapsedTime') { - setLastIdleStateChangeElapsedTime(event.data.elapsedTime > 0 ? event.data.elapsedTime : 0); - } else if (event.data.type === 'stopIdleCodeTimer') { - setLastIdleStateChangeElapsedTime(-1); - } - }; + try { + logger.info(`CC-Widgets: Initializing worker`, { + module: 'useUserState', + method: 'useEffect - initial', + }); - return () => { - logger.log('CC-Widgets: useUserState cleanup: terminating worker', { + const blob = new Blob([workerScript], {type: 'application/javascript'}); + const workerUrl = URL.createObjectURL(blob); + workerRef.current = new Worker(workerUrl); + workerRef.current.postMessage({type: 'start', startTime: Date.now()}); + workerRef.current.postMessage({type: 'startIdleCode', startTime: Date.now()}); + workerRef.current.onmessage = (event) => { + try { + if (event.data.type === 'elapsedTime') { + setElapsedTime(event.data.elapsedTime > 0 ? event.data.elapsedTime : 0); + } else if (event.data.type === 'lastIdleStateChangeElapsedTime') { + setLastIdleStateChangeElapsedTime(event.data.elapsedTime > 0 ? event.data.elapsedTime : 0); + } else if (event.data.type === 'stopIdleCodeTimer') { + setLastIdleStateChangeElapsedTime(-1); + } + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in worker onmessage - ${error.message}`, { + module: 'useUserState', + method: 'useEffect - initial - onmessage', + }); + } + }; + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error initializing worker - ${error.message}`, { module: 'useUserState', - method: 'useEffect - initial cleanup', + method: 'useEffect - initial', }); - if (workerRef.current) { - workerRef.current.postMessage({type: 'stop'}); - workerRef.current.postMessage({type: 'stopIdleCode'}); - workerRef.current.terminate(); - workerRef.current = null; + } + + return () => { + try { + logger.log('CC-Widgets: useUserState cleanup: terminating worker', { + module: 'useUserState', + method: 'useEffect - initial cleanup', + }); + if (workerRef.current) { + workerRef.current.postMessage({type: 'stop'}); + workerRef.current.postMessage({type: 'stopIdleCode'}); + workerRef.current.terminate(); + workerRef.current = null; + } + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in cleanup - ${error.message}`, { + module: 'useUserState', + method: 'useEffect - initial cleanup', + }); } }; }, []); useEffect(() => { - if (prevStateRef.current !== currentState) { - logger.info(`CC-Widgets: State change action started: ${prevStateRef.current} -> ${currentState}`, { + try { + if (prevStateRef.current !== currentState) { + logger.info(`CC-Widgets: State change action started: ${prevStateRef.current} -> ${currentState}`, { + module: 'useUserState', + method: 'useEffect - currentState', + }); + + // Call setAgentStatus and update prevStateRef after promise resolves + updateAgentState(currentState) + .then(() => { + logger.log(`CC-Widgets: State change updated: ${prevStateRef.current} -> ${currentState}`, { + module: 'useUserState', + method: 'useEffect - currentState', + }); + prevStateRef.current = currentState; + callOnStateChange(); + }) + .catch((error) => { + logger.error(`Failed to update state: ${error.toString()}`, { + module: 'useUserState', + method: 'useEffect - currentState', + }); + }); + } + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in currentState useEffect - ${error.message}`, { module: 'useUserState', method: 'useEffect - currentState', }); - - // Call setAgentStatus and update prevStateRef after promise resolves - updateAgentState(currentState) - .then(() => { - logger.log(`CC-Widgets: State change updated: ${prevStateRef.current} -> ${currentState}`, { - module: 'useUserState', - method: 'useEffect - currentState', - }); - prevStateRef.current = currentState; - callOnStateChange(); - }) - .catch((error) => { - logger.error(`Failed to update state: ${error.toString()}`, { - module: 'useUserState', - method: 'useEffect - currentState', - }); - }); } }, [currentState]); useEffect(() => { - callOnStateChange(); + try { + callOnStateChange(); + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in customState useEffect - ${error.message}`, { + module: 'useUserState', + method: 'useEffect - customState', + }); + } }, [customState]); useEffect(() => { - if (workerRef.current && lastStateChangeTimestamp) { - logger.info('CC-Widgets: useUserState timers reset', { + try { + if (workerRef.current && lastStateChangeTimestamp) { + logger.info('CC-Widgets: useUserState timers reset', { + module: 'useUserState', + method: 'useEffect - reset timers', + }); + workerRef.current.postMessage({type: 'reset', startTime: lastStateChangeTimestamp}); + + if (lastIdleCodeChangeTimestamp && lastIdleCodeChangeTimestamp !== lastStateChangeTimestamp) { + workerRef.current.postMessage({type: 'resetIdleCode', startTime: lastIdleCodeChangeTimestamp}); + } else { + workerRef.current.postMessage({type: 'stopIdleCode', startTime: lastIdleCodeChangeTimestamp}); + } + } + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in timestamp useEffect - ${error.message}`, { module: 'useUserState', method: 'useEffect - reset timers', }); - workerRef.current.postMessage({type: 'reset', startTime: lastStateChangeTimestamp}); - - if (lastIdleCodeChangeTimestamp && lastIdleCodeChangeTimestamp !== lastStateChangeTimestamp) { - workerRef.current.postMessage({type: 'resetIdleCode', startTime: lastIdleCodeChangeTimestamp}); - } else { - workerRef.current.postMessage({type: 'stopIdleCode', startTime: lastIdleCodeChangeTimestamp}); - } } }, [lastStateChangeTimestamp, lastIdleCodeChangeTimestamp]); // UI change calls this method and gets the store updated const setAgentStatus = (selectedCode) => { - logger.info('CC-Widgets: useUserState setAgentStatus(): updating currentState', { - module: 'useUserState', - method: 'setAgentStatus', - }); - store.setCurrentState(selectedCode); + try { + logger.info('CC-Widgets: useUserState setAgentStatus(): updating currentState', { + module: 'useUserState', + method: 'setAgentStatus', + }); + store.setCurrentState(selectedCode); + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in setAgentStatus - ${error.message}`, { + module: 'useUserState', + method: 'setAgentStatus', + }); + } }; // Store change calls the useEffect above which calls this method // This method updates the agent state in the backend const updateAgentState = (selectedCode) => { - selectedCode = idleCodes?.filter((code) => code.id === selectedCode)[0]; + try { + selectedCode = idleCodes?.filter((code) => code.id === selectedCode)[0]; - const {auxCodeId, state} = { - auxCodeId: selectedCode.id, - state: selectedCode.name, - }; - setIsSettingAgentStatus(true); - const chosenState = state === 'Available' ? 'Available' : 'Idle'; + const {auxCodeId, state} = { + auxCodeId: selectedCode.id, + state: selectedCode.name, + }; + setIsSettingAgentStatus(true); + const chosenState = state === 'Available' ? 'Available' : 'Idle'; - return cc - .setAgentState({state: chosenState, auxCodeId, agentId, lastStateChangeReason: state}) - .then((response) => { - logger.log(`CC-Widgets: Agent state set successfully to ${chosenState}`, { - module: 'useUserState', - method: 'updateAgentState', - }); - if ('data' in response) { - store.setLastStateChangeTimestamp(response.data.lastStateChangeTimestamp); - store.setLastIdleCodeChangeTimestamp(response.data.lastIdleCodeChangeTimestamp); - } - }) - .catch((error) => { - logger.error(`Error setting agent state: ${error.toString()}`, { - module: 'useUserState', - method: 'updateAgentState', + return cc + .setAgentState({state: chosenState, auxCodeId, agentId, lastStateChangeReason: state}) + .then((response) => { + logger.log(`CC-Widgets: Agent state set successfully to ${chosenState}`, { + module: 'useUserState', + method: 'updateAgentState', + }); + if ('data' in response) { + store.setLastStateChangeTimestamp(response.data.lastStateChangeTimestamp); + store.setLastIdleCodeChangeTimestamp(response.data.lastIdleCodeChangeTimestamp); + } + }) + .catch((error) => { + logger.error(`Error setting agent state: ${error.toString()}`, { + module: 'useUserState', + method: 'updateAgentState', + }); + store.setCurrentState(prevStateRef.current); + throw error; + }) + .finally(() => { + setIsSettingAgentStatus(false); }); - store.setCurrentState(prevStateRef.current); - throw error; - }) - .finally(() => { - setIsSettingAgentStatus(false); + } catch (error) { + logger?.error(`CC-Widgets: UserState: Error in updateAgentState - ${error.message}`, { + module: 'useUserState', + method: 'updateAgentState', }); + setIsSettingAgentStatus(false); + throw error; + } }; return { diff --git a/packages/contact-center/user-state/src/user-state/index.tsx b/packages/contact-center/user-state/src/user-state/index.tsx index 5348e89c9..aabdb26ef 100644 --- a/packages/contact-center/user-state/src/user-state/index.tsx +++ b/packages/contact-center/user-state/src/user-state/index.tsx @@ -1,12 +1,13 @@ import React from 'react'; import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; import {useUserState} from '../helper'; import {UserStateComponent, UserStateComponentsProps} from '@webex/cc-components'; import {IUserStateProps} from '../user-state.types'; -const UserState: React.FunctionComponent = observer(({onStateChange}) => { +const UserStateInternal: React.FunctionComponent = observer(({onStateChange}) => { const { cc, idleCodes, @@ -36,4 +37,17 @@ const UserState: React.FunctionComponent = observer(({onStateCh return ; }); +const UserState: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('UserState', error); + }} + > + + + ); +}; + export {UserState}; diff --git a/packages/contact-center/user-state/tests/helper.ts b/packages/contact-center/user-state/tests/helper.ts index 2f67cab75..15f090f81 100644 --- a/packages/contact-center/user-state/tests/helper.ts +++ b/packages/contact-center/user-state/tests/helper.ts @@ -1,3 +1,4 @@ +import React from 'react'; import {renderHook, act, waitFor} from '@testing-library/react'; import {useUserState} from '../src/helper'; import store from '@webex/cc-store'; @@ -106,7 +107,8 @@ describe('useUserState Hook', () => { onmessage: null, }; - global.Worker = jest.fn(() => workerMock); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).Worker = jest.fn(() => workerMock); blobMock = jest.fn(() => 'blob:http://localhost:3000/12345'); global.URL.createObjectURL = blobMock; jest.spyOn(store, 'setCurrentState'); @@ -446,4 +448,338 @@ describe('useUserState Hook', () => { expect(onStateChange).toHaveBeenCalledWith(idleCodes[0]); }); + + describe('Error Handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle errors in callOnStateChange', () => { + const errorOnStateChange = jest.fn().mockImplementation(() => { + throw new Error('Test error in onStateChange callback'); + }); + + renderHook(() => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '1', + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange: errorOnStateChange, + }) + ); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in callOnStateChange - Test error in onStateChange callback', + { + module: 'useUserState', + method: 'callOnStateChange', + } + ); + }); + + it('should handle errors in worker initialization', () => { + const originalWorker = global.Worker; + + global.Worker = jest.fn().mockImplementation(() => { + throw new Error('Worker initialization failed'); + }); + + renderHook(() => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '0', + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }) + ); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error initializing worker - Worker initialization failed', + { + module: 'useUserState', + method: 'useEffect - initial', + } + ); + + global.Worker = originalWorker; + }); + + it('should handle errors in worker onmessage handler', () => { + const mockSetElapsedTime = jest.fn().mockImplementation(() => { + throw new Error('Error in setElapsedTime'); + }); + + const originalUseState = React.useState; + // @ts-expect-error: only for testing + jest.spyOn(React, 'useState').mockImplementation((initial: unknown) => { + if (initial === 0) { + // Mock setElapsedTime to throw error + return [0, mockSetElapsedTime]; + } + // Return normal mock for other useState calls + return originalUseState(initial); + }); + + renderHook(() => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '0', + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }) + ); + + act(() => { + workerMock.onmessage({data: {type: 'elapsedTime', elapsedTime: 1}}); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in worker onmessage - Error in setElapsedTime', + { + module: 'useUserState', + method: 'useEffect - initial - onmessage', + } + ); + }); + + it('should handle errors in currentState useEffect', () => { + // Mock updateAgentState to be called directly, which might throw + const errorCC = { + ...mockCC, + setAgentState: jest.fn().mockImplementation(() => { + throw new Error('setAgentState synchronous error'); + }), + }; + + const {rerender} = renderHook( + ({currentState}) => + useUserState({ + idleCodes, + agentId, + cc: errorCC, + currentState, + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }), + {initialProps: {currentState: '0'}} + ); + + act(() => { + rerender({currentState: '1'}); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in currentState useEffect - setAgentState synchronous error', + { + module: 'useUserState', + method: 'useEffect - currentState', + } + ); + }); + + it('should handle errors in customState useEffect', () => { + // Mock onStateChange to throw error + const errorOnStateChange = jest.fn().mockImplementation(() => { + throw new Error('customState callback error'); + }); + + const {rerender} = renderHook( + ({customState}) => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '0', + customState, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange: errorOnStateChange, + }), + {initialProps: {customState: null}} + ); + + act(() => { + rerender({customState: {developerName: 'Custom State', name: 'Custom State'}}); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in callOnStateChange - customState callback error', + { + module: 'useUserState', + method: 'callOnStateChange', + } + ); + }); + + it('should handle errors in timestamp useEffect', () => { + // Mock the worker to throw error when postMessage is called + const mockWorkerWithError = { + postMessage: jest.fn().mockImplementation(() => { + throw new Error('Worker postMessage error'); + }), + terminate: jest.fn(), + onmessage: null, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).Worker = jest.fn(() => mockWorkerWithError); + + const {rerender} = renderHook( + ({lastStateChangeTimestamp}) => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '0', + customState: null, + lastStateChangeTimestamp, + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }), + {initialProps: {lastStateChangeTimestamp: new Date().getTime()}} + ); + + act(() => { + rerender({lastStateChangeTimestamp: new Date().getTime() + 1000}); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in timestamp useEffect - Worker postMessage error', + { + module: 'useUserState', + method: 'useEffect - reset timers', + } + ); + }); + + it('should handle errors in setAgentStatus', () => { + // Mock store.setCurrentState to throw error + const originalSetCurrentState = store.setCurrentState; + store.setCurrentState = jest.fn().mockImplementation(() => { + throw new Error('setCurrentState error'); + }); + + const {result} = renderHook(() => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '0', + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }) + ); + + act(() => { + result.current.setAgentStatus('1'); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in setAgentStatus - setCurrentState error', + { + module: 'useUserState', + method: 'setAgentStatus', + } + ); + + store.setCurrentState = originalSetCurrentState; + }); + + it('should handle errors in updateAgentState', () => { + // Mock idleCodes.filter to throw error + const errorIdleCodes = new Proxy([], { + get: (target, prop) => { + if (prop === 'filter') { + throw new Error('idleCodes filter error'); + } + return target[prop]; + }, + }); + + const {rerender} = renderHook( + ({currentState}) => + useUserState({ + idleCodes: errorIdleCodes, + agentId, + cc: mockCC, + currentState, + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }), + {initialProps: {currentState: '0'}} + ); + + act(() => { + rerender({currentState: '1'}); + }); + + expect(logger.error).toHaveBeenCalledWith( + 'CC-Widgets: UserState: Error in updateAgentState - idleCodes filter error', + { + module: 'useUserState', + method: 'updateAgentState', + } + ); + }); + + it('should handle errors in cleanup function', () => { + const mockWorkerWithCleanupError = { + postMessage: jest.fn(), + terminate: jest.fn().mockImplementation(() => { + throw new Error('Worker terminate error'); + }), + onmessage: null, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).Worker = jest.fn(() => mockWorkerWithCleanupError); + + const {unmount} = renderHook(() => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState: '0', + customState: null, + lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, + logger, + onStateChange, + }) + ); + + unmount(); + + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: UserState: Error in cleanup - Worker terminate error', { + module: 'useUserState', + method: 'useEffect - initial cleanup', + }); + }); + }); }); diff --git a/packages/contact-center/user-state/tests/user-state/index.tsx b/packages/contact-center/user-state/tests/user-state/index.tsx index aad1270c3..9fde381e8 100644 --- a/packages/contact-center/user-state/tests/user-state/index.tsx +++ b/packages/contact-center/user-state/tests/user-state/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {render} from '@testing-library/react'; import {UserState} from '../../src'; import * as helper from '../../src/helper'; +import store from '@webex/cc-store'; import '@testing-library/jest-dom'; // Mock the store import @@ -16,10 +17,14 @@ jest.mock('@webex/cc-store', () => { logger: { log: jest.fn(), info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), }, lastStateChangeTimestamp: new Date().getTime(), + lastIdleCodeChangeTimestamp: undefined, customState: null, currentState: '0', + onErrorCallback: jest.fn(), }; }); @@ -28,6 +33,10 @@ describe('UserState Component', () => { const onStateChange = jest.fn(); beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error for error boundary tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + workerMock = { postMessage: jest.fn(), terminate: jest.fn(), @@ -42,6 +51,10 @@ describe('UserState Component', () => { } }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders UserStateComponent with correct props', () => { const useUserStateSpy = jest.spyOn(helper, 'useUserState'); @@ -57,11 +70,32 @@ describe('UserState Component', () => { currentState: '0', customState: null, lastStateChangeTimestamp: expect.any(Number), + lastIdleCodeChangeTimestamp: undefined, logger: { log: expect.any(Function), info: expect.any(Function), + error: expect.any(Function), + warn: expect.any(Function), }, onStateChange: expect.any(Function), }); }); + + describe('ErrorBoundary Tests', () => { + it('should render empty fragment when ErrorBoundary catches an error', () => { + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + // Mock the useUserState to throw an error + jest.spyOn(helper, 'useUserState').mockImplementation(() => { + throw new Error('Test error in useUserState'); + }); + + const {container} = render(); + + // The fallback should render an empty fragment (no content) + expect(container.firstChild).toBeNull(); + expect(mockOnErrorCallback).toHaveBeenCalledWith('UserState', Error('Test error in useUserState')); + expect(mockOnErrorCallback).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/contact-center/user-state/tsconfig.test.json b/packages/contact-center/user-state/tsconfig.test.json index 40bd84f5a..1b8766912 100644 --- a/packages/contact-center/user-state/tsconfig.test.json +++ b/packages/contact-center/user-state/tsconfig.test.json @@ -2,7 +2,7 @@ { "extends": "./tsconfig.json", "include": ["./tests"], - "exclude": ["node_modules"], + "exclude": ["**/node_modules/**"], "compilerOptions": { "noEmit": true // Don't output any files } diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 1f3d9e0fe..af134eff5 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -342,13 +342,19 @@ function App() { useEffect(() => { store.setIncomingTaskCb(onIncomingTaskCB); + store.setOnError(onError); return () => { + store.setOnError(undefined); store.setTaskRejected(undefined); store.setIncomingTaskCb(undefined); }; }, []); + const onError = (widgetName: string, error: Error) => { + console.log('Error in widgets:', widgetName, error); + }; + const onStateChange = (status) => { console.log('onStateChange invoked', status); //adding a log to be used for automation diff --git a/widgets-samples/cc/samples-cc-react-app/webpack.config.js b/widgets-samples/cc/samples-cc-react-app/webpack.config.js index 86fb2c134..768708b69 100644 --- a/widgets-samples/cc/samples-cc-react-app/webpack.config.js +++ b/widgets-samples/cc/samples-cc-react-app/webpack.config.js @@ -1,15 +1,44 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const {merge} = require('webpack-merge'); - -const baseConfig = require('../../../webpack.config'); - +const {HotModuleReplacementPlugin, ProvidePlugin} = require('webpack'); // Helper function to resolve paths relative to the monorepo root const resolveMonorepoRoot = (...segments) => path.resolve(__dirname, '../../../', ...segments); -module.exports = merge(baseConfig, { +// Workspaces we want to alias/include +const PKG_SRC = [ + 'packages/contact-center/store/src', + 'packages/contact-center/cc-widgets/src', + 'packages/contact-center/station-login/src', + 'packages/contact-center/user-state/src', + 'packages/contact-center/task/src', + 'packages/contact-center/cc-components/src', + 'packages/contact-center/ui-logging/src', +].map((p) => resolveMonorepoRoot(p)); + +module.exports = { + mode: process.env.NODE_ENV || 'development', + entry: './src/index.tsx', // Entry file for bundling + + output: { + path: path.resolve(__dirname, '../../../docs/samples-cc-react-app'), + filename: 'bundle.js', + clean: true, + }, + resolve: { + fallback: { + fs: false, + process: require.resolve('process/browser'), + crypto: require.resolve('crypto-browserify'), + querystring: require.resolve('querystring-es3'), + os: require.resolve('os-browserify/browser'), + stream: require.resolve('stream-browserify'), + vm: require.resolve('vm-browserify'), + util: require.resolve('util/'), + url: require.resolve('url/'), + }, + extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], alias: { '@webex/cc-store': path.resolve(__dirname, '../../../packages/contact-center/store/src'), '@webex/cc-widgets': path.resolve(__dirname, '../../../packages/contact-center/cc-widgets/src'), @@ -19,9 +48,19 @@ module.exports = merge(baseConfig, { '@webex/cc-components': path.resolve(__dirname, '../../../packages/contact-center/cc-components/src'), '@webex/cc-ui-logging': path.resolve(__dirname, '../../../packages/contact-center/ui-logging/src'), }, + symlinks: true, }, module: { rules: [ + // TS/TSX → transpile only (skip type-checking) + { + test: /\.[jt]sx?$/, + include: [path.resolve(__dirname, 'src'), ...PKG_SRC], + loader: 'ts-loader', + options: { + transpileOnly: true, // ✅ disables type-checking + }, + }, { test: /\.css$/, use: ['style-loader', 'css-loader'], @@ -29,20 +68,23 @@ module.exports = merge(baseConfig, { resolveMonorepoRoot('node_modules/@momentum-ui'), // Include specific node module, resolveMonorepoRoot('node_modules/react-toastify'), // Include specific node module resolveMonorepoRoot('node_modules/@momentum-design'), - path.resolve(__dirname, 'widgets-samples/cc'), // Include all CSS from the local package + path.resolve(__dirname, 'src'), + ...PKG_SRC, ], }, { test: /\.scss$/, use: [ - 'style-loader', // Injects styles into DOM - 'css-loader', // Turns CSS into CommonJS - 'sass-loader', // Compiles Sass to CSS + 'style-loader', + {loader: 'css-loader', options: {importLoaders: 1}}, + {loader: 'sass-loader', options: {implementation: require('sass')}}, ], include: [ resolveMonorepoRoot('node_modules/@momentum-ui'), // Include specific node module path.resolve(__dirname, 'widgets-samples/cc'), // Include all CSS from the local package resolveMonorepoRoot('node_modules/@momentum-design'), + path.resolve(__dirname, 'src'), // your app + ...PKG_SRC, // all workspace packages ], }, { @@ -80,7 +122,12 @@ module.exports = merge(baseConfig, { template: './public/index.html', // Template HTML file filename: 'index.html', }), + new ProvidePlugin({ + process: 'process/browser', + }), + new HotModuleReplacementPlugin(), ], + devServer: { static: path.join(__dirname, 'public'), // Serve files from public folder compress: true, // Enable gzip compression @@ -88,5 +135,18 @@ module.exports = merge(baseConfig, { hot: true, // Enable hot module replacement open: false, // Open the app in browser on start liveReload: true, // Reload page on changes + watchFiles: { + paths: [path.resolve(__dirname, 'src'), ...PKG_SRC], + }, + }, + + watchOptions: { + followSymlinks: true, + ignored: /node_modules\/(?!@webex)/, + }, + stats: { + // While building and running the sample app when sass-loader is used we get a lot of deprecation warnings + // This is a workaround to suppress them untill we move away from sass-loader + warningsFilter: [/sass-loader/], }, -}); +}; diff --git a/yarn.lock b/yarn.lock index 694ca53dc..5717dcc00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9472,6 +9472,7 @@ __metadata: jest-environment-jsdom: "npm:29.7.0" mobx-react-lite: "npm:^4.1.0" prettier: "npm:^3.5.1" + react-error-boundary: "npm:^6.0.0" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" typescript-eslint: "npm:^8.24.1" @@ -9559,6 +9560,7 @@ __metadata: jest-environment-jsdom: "npm:29.7.0" mobx-react-lite: "npm:^4.1.0" prettier: "npm:^3.5.1" + react-error-boundary: "npm:^6.0.0" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" typescript-eslint: "npm:^8.24.1" @@ -9612,6 +9614,7 @@ __metadata: "@types/react-test-renderer": "npm:18" "@webex/cc-components": "workspace:*" "@webex/cc-store": "workspace:*" + "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" babel-loader: "npm:9.2.1" eslint: "npm:^9.20.1" @@ -9628,6 +9631,7 @@ __metadata: jest-environment-jsdom: "npm:29.7.0" mobx-react-lite: "npm:^4.1.0" prettier: "npm:^3.5.1" + react-error-boundary: "npm:^6.0.0" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" typescript-eslint: "npm:^8.24.1" @@ -30004,6 +30008,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^6.0.0": + version: 6.0.0 + resolution: "react-error-boundary@npm:6.0.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8 + languageName: node + linkType: hard + "react-focus-lock@npm:2.11.3": version: 2.11.3 resolution: "react-focus-lock@npm:2.11.3"