From 8bbcae1c5ba6fda8aea9d81aa0064e77d7e63ca3 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma <72344404+Shreyas281299@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:57:43 +0530 Subject: [PATCH] fix(store): direct-all-events-through-store (#369) Co-authored-by: Rajesh Kumar --- .../contact-center/station-login/package.json | 2 +- .../station-login/src/helper.ts | 28 +- .../src/station-login/alert-modal.scss | 37 -- .../src/station-login/constants.ts | 2 - .../station-login/src/station-login/index.tsx | 5 +- .../station-login.presentational.tsx | 29 +- .../station-login/station-login.style.scss | 38 ++ .../src/station-login/station-login.types.ts | 2 +- .../station-login/tests/helper.ts | 274 ++++++-- .../tests/station-login/index.tsx | 6 + .../station-login.presentational.tsx | 30 - packages/contact-center/store/package.json | 4 +- packages/contact-center/store/src/index.ts | 2 +- packages/contact-center/store/src/store.ts | 39 +- .../contact-center/store/src/store.types.ts | 67 +- .../store/src/storeEventsWrapper.ts | 256 ++++++++ packages/contact-center/store/tests/store.ts | 96 ++- .../store/tests/storeEventsWrapper.ts | 612 ++++++++++++++++++ packages/contact-center/task/package.json | 2 +- .../call-control.presentational.tsx | 21 +- .../task/src/CallControl/index.tsx | 5 +- .../incoming-task.presentational.tsx | 5 +- .../task/src/IncomingTask/index.tsx | 5 +- .../task/src/TaskList/index.tsx | 4 +- packages/contact-center/task/src/helper.ts | 115 +--- .../contact-center/task/src/task.types.ts | 18 +- .../call-control.presentational.tsx | 29 + .../task/tests/CallControl/index.tsx | 2 +- .../task/tests/IncomingTask/index.tsx | 4 +- .../task/tests/TaskList/index.tsx | 4 +- packages/contact-center/task/tests/helper.ts | 434 ++++++------- .../contact-center/user-state/package.json | 2 +- .../user-state/src/constants.ts | 1 - .../contact-center/user-state/src/helper.ts | 41 +- .../contact-center/user-state/tests/helper.ts | 78 +-- .../cc/samples-cc-react-app/src/App.tsx | 77 ++- widgets-samples/cc/samples-cc-wc-app/app.js | 158 +++-- .../cc/samples-cc-wc-app/index.html | 32 +- yarn.lock | 34 +- 39 files changed, 1825 insertions(+), 775 deletions(-) delete mode 100644 packages/contact-center/station-login/src/station-login/alert-modal.scss create mode 100644 packages/contact-center/store/src/storeEventsWrapper.ts create mode 100644 packages/contact-center/store/tests/storeEventsWrapper.ts delete mode 100644 packages/contact-center/user-state/src/constants.ts diff --git a/packages/contact-center/station-login/package.json b/packages/contact-center/station-login/package.json index 3aa3419c3..1873c48ba 100644 --- a/packages/contact-center/station-login/package.json +++ b/packages/contact-center/station-login/package.json @@ -16,7 +16,7 @@ "build": "yarn run -T tsc", "build:src": "yarn run clean:dist && webpack", "build:watch": "webpack --watch", - "test:unit": "jest" + "test:unit": "jest --coverage" }, "dependencies": { "@webex/cc-store": "workspace:*", diff --git a/packages/contact-center/station-login/src/helper.ts b/packages/contact-center/station-login/src/helper.ts index 1cda818d9..bafa62ea6 100644 --- a/packages/contact-center/station-login/src/helper.ts +++ b/packages/contact-center/station-login/src/helper.ts @@ -2,7 +2,6 @@ import {useState, useEffect} from 'react'; import {StationLoginSuccess, StationLogoutSuccess} from '@webex/plugin-cc'; import {UseStationLoginProps} from './station-login/station-login.types'; import store from '@webex/cc-store'; // we need to import as we are losing the context of this in store -import {AGENT_MULTI_LOGIN} from './station-login/constants'; export const useStationLogin = (props: UseStationLoginProps) => { const cc = props.cc; @@ -11,26 +10,11 @@ export const useStationLogin = (props: UseStationLoginProps) => { const logger = props.logger; const [isAgentLoggedIn, setIsAgentLoggedIn] = useState(props.isAgentLoggedIn); const [dialNumber, setDialNumber] = useState(''); - const [deviceType, setDeviceType] = useState(''); + const [deviceType, setDeviceType] = useState(props.deviceType || ''); const [team, setTeam] = useState(''); const [loginSuccess, setLoginSuccess] = useState(); const [loginFailure, setLoginFailure] = useState(); const [logoutSuccess, setLogoutSuccess] = useState(); - const [showMultipleLoginAlert, setShowMultipleLoginAlert] = useState(false); - - useEffect(() => { - const handleMultiLoginCloseSession = (data) => { - if (data && typeof data === 'object' && data.type === 'AgentMultiLoginCloseSession') { - setShowMultipleLoginAlert(true); - } - }; - - cc.on(AGENT_MULTI_LOGIN, handleMultiLoginCloseSession); - - return () => { - cc.off(AGENT_MULTI_LOGIN, handleMultiLoginCloseSession); - }; - }, [cc]); useEffect(() => { setIsAgentLoggedIn(props.isAgentLoggedIn); @@ -38,7 +22,7 @@ export const useStationLogin = (props: UseStationLoginProps) => { const handleContinue = async () => { try { - setShowMultipleLoginAlert(false); + store.setShowMultipleLoginAlert(false); await store.registerCC(); if (store.isAgentLoggedIn) { logger.log(`Agent Relogin Success`, { @@ -64,7 +48,8 @@ export const useStationLogin = (props: UseStationLoginProps) => { .then((res: StationLoginSuccess) => { setLoginSuccess(res); setIsAgentLoggedIn(true); - store.setSelectedLoginOption(deviceType); + store.setDeviceType(deviceType); + store.setIsAgentLoggedIn(true); if (res.data.auxCodeId) { store.setCurrentState(res.data.auxCodeId); } @@ -89,6 +74,8 @@ export const useStationLogin = (props: UseStationLoginProps) => { .then((res: StationLogoutSuccess) => { setLogoutSuccess(res); setIsAgentLoggedIn(false); + store.setIsAgentLoggedIn(false); + store.setDeviceType(''); if (logoutCb) { logoutCb(); } @@ -102,7 +89,7 @@ export const useStationLogin = (props: UseStationLoginProps) => { }; function relogin() { - store.setSelectedLoginOption(deviceType); + store.setDeviceType(deviceType); if (loginCb) { loginCb(); } @@ -119,7 +106,6 @@ export const useStationLogin = (props: UseStationLoginProps) => { loginSuccess, loginFailure, logoutSuccess, - showMultipleLoginAlert, isAgentLoggedIn, handleContinue, }; diff --git a/packages/contact-center/station-login/src/station-login/alert-modal.scss b/packages/contact-center/station-login/src/station-login/alert-modal.scss deleted file mode 100644 index 138eb3652..000000000 --- a/packages/contact-center/station-login/src/station-login/alert-modal.scss +++ /dev/null @@ -1,37 +0,0 @@ -.modal { - width: 400px; - border: none; - border-radius: 10px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - padding: 20px; - text-align: center; - } - - .modal-content { - display: flex; - justify-content: flex-end; // Aligns the button to the right - } - - .modal::backdrop { - background: rgba(0, 0, 0, 0.5); - } - - h2 { - margin-top: 0; - } - - #ContinueButton { - background-color: #0078d4; - color: white; - border: none; - padding: 10px 20px; - border-radius: 5px; - cursor: pointer; - font-size: 16px; - cursor: pointer; - } - - #ContinueButton:hover { - background-color: #005a9e; - } - \ No newline at end of file diff --git a/packages/contact-center/station-login/src/station-login/constants.ts b/packages/contact-center/station-login/src/station-login/constants.ts index d42fff983..0705defb0 100644 --- a/packages/contact-center/station-login/src/station-login/constants.ts +++ b/packages/contact-center/station-login/src/station-login/constants.ts @@ -2,5 +2,3 @@ export const MULTIPLE_SIGN_IN_ALERT_MESSAGE = 'You are signed in to the Desktop in multiple application instances. Click Continue to proceed with the Desktop in this application instance. Else, close this window.'; export const MULTIPLE_SIGN_IN_ALERT_TITLE = 'Multiple Sign In Alert'; - -export const AGENT_MULTI_LOGIN = 'agent:multiLogin'; 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 ef75b5514..8411ec2e8 100644 --- a/packages/contact-center/station-login/src/station-login/index.tsx +++ b/packages/contact-center/station-login/src/station-login/index.tsx @@ -7,13 +7,14 @@ import {useStationLogin} from '../helper'; import {StationLoginProps} from './station-login.types'; const StationLoginComponent: React.FunctionComponent = ({onLogin, onLogout}) => { - const {cc, teams, loginOptions, logger, deviceType, isAgentLoggedIn} = store; + const {cc, teams, loginOptions, logger, isAgentLoggedIn, showMultipleLoginAlert, deviceType} = store; const result = useStationLogin({ cc, onLogin, onLogout, logger, isAgentLoggedIn, + deviceType, }); const props = { @@ -22,7 +23,7 @@ const StationLoginComponent: React.FunctionComponent = ({onLo loginOptions, deviceType, }; - return ; + return ; }; const StationLogin = observer(StationLoginComponent); diff --git a/packages/contact-center/station-login/src/station-login/station-login.presentational.tsx b/packages/contact-center/station-login/src/station-login/station-login.presentational.tsx index e32c9a2cb..6c7fe8ea4 100644 --- a/packages/contact-center/station-login/src/station-login/station-login.presentational.tsx +++ b/packages/contact-center/station-login/src/station-login/station-login.presentational.tsx @@ -1,11 +1,24 @@ -import React, { useEffect, useRef} from 'react'; -import { StationLoginPresentationalProps } from './station-login.types'; +import React, {useEffect, useRef} from 'react'; +import {StationLoginPresentationalProps} from './station-login.types'; import './station-login.style.scss'; -import { MULTIPLE_SIGN_IN_ALERT_MESSAGE, MULTIPLE_SIGN_IN_ALERT_TITLE } from './constants'; -import './alert-modal.scss'; +import {MULTIPLE_SIGN_IN_ALERT_MESSAGE, MULTIPLE_SIGN_IN_ALERT_TITLE} from './constants'; const StationLoginPresentational: React.FunctionComponent = (props) => { - const { name, teams, loginOptions, login, logout, relogin, setDeviceType, setDialNumber, setTeam, isAgentLoggedIn, deviceType, showMultipleLoginAlert, handleContinue} = props; // TODO: Use the loginSuccess, loginFailure, logoutSuccess props returned fromthe API response via helper file to reflect UI changes + const { + name, + teams, + loginOptions, + login, + logout, + relogin, + setDeviceType, + setDialNumber, + setTeam, + isAgentLoggedIn, + deviceType, + showMultipleLoginAlert, + handleContinue, + } = props; // TODO: Use the loginSuccess, loginFailure, logoutSuccess props returned fromthe API response via helper file to reflect UI changes const modalRef = useRef(null); useEffect(() => { @@ -33,6 +46,9 @@ const StationLoginPresentational: React.FunctionComponent - { + const selectLoginOption = (event: {target: {value: string}}) => { const dialNumber = document.querySelector('#dialNumber') as HTMLInputElement; const deviceType = event.target.value; setDeviceType(deviceType); diff --git a/packages/contact-center/station-login/src/station-login/station-login.style.scss b/packages/contact-center/station-login/src/station-login/station-login.style.scss index feea0b874..d15784c36 100644 --- a/packages/contact-center/station-login/src/station-login/station-login.style.scss +++ b/packages/contact-center/station-login/src/station-login/station-login.style.scss @@ -53,3 +53,41 @@ border: 1px solid #ccc; border-radius: 4px; } + + +.modal { + width: 400px; + border: none; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + padding: 20px; + text-align: center; +} + +.modal-content { + display: flex; + justify-content: flex-end; // Aligns the button to the right +} + +.modal::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +h2 { + margin-top: 0; +} + +#ContinueButton { + background-color: #0078d4; + color: white; + border: none; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + cursor: pointer; +} + +#ContinueButton:hover { + background-color: #005a9e; +} diff --git a/packages/contact-center/station-login/src/station-login/station-login.types.ts b/packages/contact-center/station-login/src/station-login/station-login.types.ts index 26c686c22..7508eb4fe 100644 --- a/packages/contact-center/station-login/src/station-login/station-login.types.ts +++ b/packages/contact-center/station-login/src/station-login/station-login.types.ts @@ -128,7 +128,7 @@ export type StationLoginPresentationalProps = Pick< export type UseStationLoginProps = Pick< IStationLoginProps, - 'cc' | 'onLogin' | 'onLogout' | 'logger' | 'isAgentLoggedIn' + 'cc' | 'onLogin' | 'onLogout' | 'logger' | 'isAgentLoggedIn' | 'deviceType' >; export type StationLoginProps = Pick; diff --git a/packages/contact-center/station-login/tests/helper.ts b/packages/contact-center/station-login/tests/helper.ts index 45e762389..bf088f9ba 100644 --- a/packages/contact-center/station-login/tests/helper.ts +++ b/packages/contact-center/station-login/tests/helper.ts @@ -10,9 +10,12 @@ jest.mock('@webex/cc-store', () => { cc: {}, teams, loginOptions, - setSelectedLoginOption: jest.fn(), + registerCC: jest.fn(), + setDeviceType: jest.fn(), setCurrentState: jest.fn(), setLastStateChangeTimestamp: jest.fn(), + setShowMultipleLoginAlert: jest.fn(), + setIsAgentLoggedIn: jest.fn(), }; }); @@ -22,7 +25,6 @@ const ccMock = { stationLogout: jest.fn(), on: jest.fn(), off: jest.fn(), - register: jest.fn(), }; // Sample login parameters @@ -50,31 +52,33 @@ describe('useStationLogin Hook', () => { }); it('should set loginSuccess on successful login', async () => { - const successResponse = {data: { - agentId: '6b310dff-569e-4ac7-b064-70f834ea56d8', - agentSessionId: 'c9c24ace-5170-4a9f-8bc2-2eeeff9d7c11', - auxCodeId: '00b4e8df-f7b0-460f-aacf-f1e635c87d4d', - deviceId: '1001', - deviceType: 'EXTENSION', - dn: '1001', - eventType: 'AgentDesktopMessage', - interactionIds: [], - lastIdleCodeChangeTimestamp: 1731997914706, - lastStateChangeTimestamp: 1731997914706, - orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', - profileType: 'BLENDED', - roles: ['agent'], - siteId: 'd64e19c0-53a2-4ae0-ab7e-3ebc778b3dcd', - status: 'LoggedIn', - subStatus: 'Idle', - teamId: 'c789288e-39e3-40c9-8e66-62c6276f73de', - trackingId: 'f40915b9-07ed-4b6c-832d-e7f5e7af3b72', - type: 'AgentStationLoginSuccess', - voiceCount: 1, - }}; + const successResponse = { + data: { + agentId: '6b310dff-569e-4ac7-b064-70f834ea56d8', + agentSessionId: 'c9c24ace-5170-4a9f-8bc2-2eeeff9d7c11', + auxCodeId: '00b4e8df-f7b0-460f-aacf-f1e635c87d4d', + deviceId: '1001', + deviceType: 'EXTENSION', + dn: '1001', + eventType: 'AgentDesktopMessage', + interactionIds: [], + lastIdleCodeChangeTimestamp: 1731997914706, + lastStateChangeTimestamp: 1731997914706, + orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', + profileType: 'BLENDED', + roles: ['agent'], + siteId: 'd64e19c0-53a2-4ae0-ab7e-3ebc778b3dcd', + status: 'LoggedIn', + subStatus: 'Idle', + teamId: 'c789288e-39e3-40c9-8e66-62c6276f73de', + trackingId: 'f40915b9-07ed-4b6c-832d-e7f5e7af3b72', + type: 'AgentStationLoginSuccess', + voiceCount: 1, + }, + }; ccMock.stationLogin.mockResolvedValue(successResponse); - const setSelectedLoginOptionSpy = jest.spyOn(require('@webex/cc-store'), 'setSelectedLoginOption'); + const setDeviceTypeSpy = jest.spyOn(require('@webex/cc-store'), 'setDeviceType'); const setSetCurrentStateSpy = jest.spyOn(require('@webex/cc-store'), 'setCurrentState'); const setSetLastStateChangeTimestampSpy = jest.spyOn(require('@webex/cc-store'), 'setLastStateChangeTimestamp'); const {result} = renderHook(() => @@ -84,8 +88,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'BROWSER', }) ); @@ -119,11 +122,10 @@ describe('useStationLogin Hook', () => { loginFailure: undefined, logoutSuccess: undefined, relogin: expect.any(Function), - showMultipleLoginAlert: false, handleContinue: expect.any(Function), }); - expect(setSelectedLoginOptionSpy).toHaveBeenCalledWith(loginParams.loginOption); + expect(setDeviceTypeSpy).toHaveBeenCalledWith(loginParams.loginOption); expect(setSetCurrentStateSpy).toHaveBeenCalledWith(successResponse.data.auxCodeId); expect(setSetLastStateChangeTimestampSpy).toHaveBeenCalledWith( new Date(successResponse.data.lastStateChangeTimestamp) @@ -131,10 +133,87 @@ describe('useStationLogin Hook', () => { }); }); - it('should not call setSelectedLoginOptionSpy if login fails', async () => { + it('should set loginSuccess on successful login without auxCode and last state timestamp', async () => { + const successResponse = { + data: { + agentId: '6b310dff-569e-4ac7-b064-70f834ea56d8', + agentSessionId: 'c9c24ace-5170-4a9f-8bc2-2eeeff9d7c11', + lastStateChangeTimestamp: 'mockDate', + }, + }; + + ccMock.stationLogin.mockResolvedValue(successResponse); + const setDeviceTypeSpy = jest.spyOn(require('@webex/cc-store'), 'setDeviceType'); + const setSetCurrentStateSpy = jest.spyOn(require('@webex/cc-store'), 'setCurrentState'); + const setSetLastStateChangeTimestampSpy = jest.spyOn(require('@webex/cc-store'), 'setLastStateChangeTimestamp'); + const {result} = renderHook(() => + useStationLogin({ + cc: ccMock, + onLogin: loginCb, + onLogout: logoutCb, + logger, + isAgentLoggedIn, + deviceType: '', + }) + ); + + act(() => { + result.current.setDeviceType(loginParams.loginOption); + result.current.setDialNumber(loginParams.dialNumber); + result.current.setTeam(loginParams.teamId); + }); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(async () => { + expect(setDeviceTypeSpy).toHaveBeenCalledWith(loginParams.loginOption); + expect(setSetCurrentStateSpy).not.toHaveBeenCalledWith(successResponse.data.auxCodeId); + expect(setSetLastStateChangeTimestampSpy).not.toHaveBeenCalledWith( + new Date(successResponse.data.lastStateChangeTimestamp) + ); + }); + }); + + it('should set loginSuccess on successful login without onLogin callback', async () => { + const successResponse = { + data: { + agentId: '6b310dff-569e-4ac7-b064-70f834ea56d8', + agentSessionId: 'c9c24ace-5170-4a9f-8bc2-2eeeff9d7c11', + }, + }; + + ccMock.stationLogin.mockResolvedValue(successResponse); + const {result} = renderHook(() => + useStationLogin({ + cc: ccMock, + onLogout: logoutCb, + logger, + isAgentLoggedIn, + deviceType: 'EXTENSION', + }) + ); + + act(() => { + result.current.setDeviceType(loginParams.loginOption); + result.current.setDialNumber(loginParams.dialNumber); + result.current.setTeam(loginParams.teamId); + }); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(async () => { + expect(loginCb).not.toHaveBeenCalledWith(); + }); + }); + + it('should not call setDeviceType if login fails', async () => { const errorResponse = new Error('Login failed'); ccMock.stationLogin.mockRejectedValue(errorResponse); - const setSelectedLoginOptionSpy = jest.spyOn(require('@webex/cc-store'), 'setSelectedLoginOption'); + const setDeviceTypeSpy = jest.spyOn(require('@webex/cc-store'), 'setDeviceType'); loginCb.mockClear(); const {result} = renderHook(() => @@ -144,8 +223,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); @@ -179,11 +257,10 @@ describe('useStationLogin Hook', () => { loginFailure: errorResponse, logoutSuccess: undefined, relogin: expect.any(Function), - showMultipleLoginAlert: false, handleContinue: expect.any(Function), }); - expect(setSelectedLoginOptionSpy).not.toHaveBeenCalled(); + expect(setDeviceTypeSpy).not.toHaveBeenCalled(); }); }); @@ -196,8 +273,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); @@ -222,8 +298,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); @@ -258,6 +333,7 @@ describe('useStationLogin Hook', () => { loginFailure: errorResponse, logoutSuccess: undefined, relogin: expect.any(Function), + handleContinue: expect.any(Function), }); }); }); @@ -287,8 +363,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'BROWSER', }) ); @@ -312,7 +387,6 @@ describe('useStationLogin Hook', () => { loginFailure: undefined, logoutSuccess: successResponse, relogin: expect.any(Function), - showMultipleLoginAlert: false, handleContinue: expect.any(Function), }); }); @@ -328,8 +402,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); @@ -354,8 +427,7 @@ describe('useStationLogin Hook', () => { onLogin: loginCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); @@ -369,7 +441,7 @@ describe('useStationLogin Hook', () => { }); it('should call relogin and set device type', async () => { - const setSelectedLoginOptionSpy = jest.spyOn(require('@webex/cc-store'), 'setSelectedLoginOption'); + const setDeviceTypeSpy = jest.spyOn(require('@webex/cc-store'), 'setDeviceType'); const {result} = renderHook(() => useStationLogin({ @@ -378,8 +450,7 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); @@ -388,12 +459,100 @@ describe('useStationLogin Hook', () => { }); await waitFor(() => { - expect(setSelectedLoginOptionSpy).toHaveBeenCalled(); + expect(setDeviceTypeSpy).toHaveBeenCalled(); expect(loginCb).toHaveBeenCalled(); }); }); - it('should handle AgentMultiLogin event', async () => { + it('should call relogin without login callback', async () => { + const setDeviceTypeSpy = jest.spyOn(require('@webex/cc-store'), 'setDeviceType'); + + const {result} = renderHook(() => + useStationLogin({ + cc: ccMock, + onLogout: logoutCb, + logger, + isAgentLoggedIn, + deviceType: 'EXTENSION', + }) + ); + + act(() => { + result.current.relogin(); + }); + + await waitFor(() => { + expect(setDeviceTypeSpy).toHaveBeenCalled(); + expect(loginCb).not.toHaveBeenCalled(); + }); + }); + + it('should call handleContinue and set device type', async () => { + const setShowMultipleLoginAlertSpy = jest.spyOn(require('@webex/cc-store'), 'setShowMultipleLoginAlert'); + require('@webex/cc-store').isAgentLoggedIn = true; + const registerCCSpy = jest.spyOn(require('@webex/cc-store'), 'registerCC'); + + const {result} = renderHook(() => + useStationLogin({ + cc: ccMock, + onLogin: loginCb, + onLogout: logoutCb, + logger, + isAgentLoggedIn, + deviceType: 'EXTENSION', + }) + ); + + act(() => { + result.current.handleContinue(); + }); + + await waitFor(() => { + expect(setShowMultipleLoginAlertSpy).toHaveBeenCalledWith(false); + expect(registerCCSpy).toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith('Agent Relogin Success', { + module: 'widget-station-login#station-login/helper.ts', + method: 'handleContinue', + }); + }); + }); + + it('should call handleContinue with agent not logged in', async () => { + require('@webex/cc-store').isAgentLoggedIn = false; + const setShowMultipleLoginAlertSpy = jest.spyOn(require('@webex/cc-store'), 'setShowMultipleLoginAlert'); + const registerCCSpy = jest.spyOn(require('@webex/cc-store'), 'registerCC'); + + const {result} = renderHook(() => + useStationLogin({ + cc: ccMock, + onLogin: loginCb, + onLogout: logoutCb, + logger, + isAgentLoggedIn, + deviceType: 'EXTENSION', + }) + ); + + act(() => { + result.current.handleContinue(); + }); + + await waitFor(() => { + expect(setShowMultipleLoginAlertSpy).toHaveBeenCalledWith(false); + expect(registerCCSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Agent Relogin Failed', { + module: 'widget-station-login#station-login/helper.ts', + method: 'handleContinue', + }); + }); + }); + + it('should call handleContinue and handle error', async () => { + const setShowMultipleLoginAlertSpy = jest.spyOn(require('@webex/cc-store'), 'setShowMultipleLoginAlert'); + const registerCCSpy = jest.spyOn(require('@webex/cc-store'), 'registerCC').mockImplementation(() => { + throw Error('Relogin failed'); + }); + const {result} = renderHook(() => useStationLogin({ cc: ccMock, @@ -401,18 +560,21 @@ describe('useStationLogin Hook', () => { onLogout: logoutCb, logger, isAgentLoggedIn, - handleContinue: jest.fn(), - showMultipleLoginAlert: false, + deviceType: 'EXTENSION', }) ); - const event = new Event('AgentMultiLoginCloseSession'); act(() => { - ccMock.on.mock.calls[0][1](event); + result.current.handleContinue(); }); await waitFor(() => { - expect(result.current.showMultipleLoginAlert).toBe(true); + expect(setShowMultipleLoginAlertSpy).toHaveBeenCalledWith(false); + expect(registerCCSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Error handling agent multi login continue: Error: Relogin failed', { + module: 'widget-station-login#station-login/index.tsx', + method: 'handleContinue', + }); }); }); }); 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 59fbcf585..573637c2c 100644 --- a/packages/contact-center/station-login/tests/station-login/index.tsx +++ b/packages/contact-center/station-login/tests/station-login/index.tsx @@ -8,6 +8,7 @@ const teams = ['team123', 'team456']; const loginOptions = ['EXTENSION', 'AGENT_DN', 'BROWSER']; const deviceType = 'BROWSER'; +const logger = {}; // Mock the store import jest.mock('@webex/cc-store', () => { @@ -19,6 +20,8 @@ jest.mock('@webex/cc-store', () => { teams, loginOptions, deviceType, + logger, + isAgentLoggedIn: false, }; }); @@ -38,6 +41,9 @@ describe('StationLogin Component', () => { }, onLogin: loginCb, onLogout: logoutCb, + logger, + isAgentLoggedIn: false, + deviceType: 'BROWSER', }); const heading = screen.getByTestId('station-login-heading'); expect(heading).toHaveTextContent('StationLogin'); diff --git a/packages/contact-center/station-login/tests/station-login/station-login.presentational.tsx b/packages/contact-center/station-login/tests/station-login/station-login.presentational.tsx index ec7d95f8b..3f2a0880c 100644 --- a/packages/contact-center/station-login/tests/station-login/station-login.presentational.tsx +++ b/packages/contact-center/station-login/tests/station-login/station-login.presentational.tsx @@ -34,36 +34,6 @@ describe('StationLoginPresentational', () => { expect(heading).toHaveTextContent('StationLogin'); }); - it('calls setDeviceType and relogin on relogin', () => { - const setDeviceType = jest.fn(); - const reloginMock = jest.fn(); - const handleContinueMock = jest.fn(); - const modalRef = React.createRef(); - const props = { - name: 'StationLogin', - login: jest.fn(), - logout: jest.fn(), - loginSuccess: undefined, - loginFailure: undefined, - logoutSuccess: undefined, - teams: ['team123'], - loginOptions: ['EXTENSION', 'AGENT_DN', 'BROWSER'], - setDeviceType, - setDialNumber: jest.fn(), - setTeam: jest.fn(), - isAgentLoggedIn: true, - deviceType: 'EXTENSION', - relogin: reloginMock, - handleContinue: handleContinueMock, - modalRef, - showMultipleLoginAlert: false, - }; - render(); - - expect(setDeviceType).toHaveBeenCalledWith('EXTENSION'); - expect(reloginMock).toHaveBeenCalled(); - }); - it('calls handleContinue function and closes the dialog when Continue button is clicked', () => { const handleContinueMock = jest.fn(); const modalRef = React.createRef(); diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index f42fd9f6c..1eabf3fef 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -17,12 +17,12 @@ "build": "yarn run -T tsc", "build:src": "yarn run clean:dist && webpack", "build:watch": "webpack --watch", - "test:unit": "jest" + "test:unit": "jest --coverage" }, "dependencies": { "mobx": "6.13.5", "typescript": "5.6.3", - "webex": "3.7.0-wxcc.12" + "webex": "3.7.0-wxcc.15" }, "devDependencies": { "@babel/core": "7.25.2", diff --git a/packages/contact-center/store/src/index.ts b/packages/contact-center/store/src/index.ts index 137f458b7..9861d6160 100644 --- a/packages/contact-center/store/src/index.ts +++ b/packages/contact-center/store/src/index.ts @@ -1,3 +1,3 @@ -import store from './store'; +import store from './storeEventsWrapper'; export * from './store.types'; export default store; diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index c07ca6943..491bfd4b8 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -10,6 +10,7 @@ import { IStore, ILogger, IWrapupCode, + TASK_EVENTS, } from './store.types'; import {ITask} from '@webex/plugin-cc'; @@ -21,33 +22,29 @@ class Store implements IStore { logger: ILogger; idleCodes: IdleCode[] = []; agentId: string = ''; - selectedLoginOption: string = ''; currentTheme: string = 'LIGHT'; wrapupCodes: IWrapupCode[] = []; + incomingTask: ITask = null; currentTask: ITask = null; isAgentLoggedIn = false; deviceType: string = ''; + taskList: ITask[] = []; + wrapupRequired: boolean = false; currentState: string = ''; lastStateChangeTimestamp: Date = new Date(); + showMultipleLoginAlert: boolean = false; + constructor() { makeAutoObservable(this, { cc: observable.ref, currentTask: observable, // Make currentTask observable + incomingTask: observable, + taskList: observable, + wrapupRequired: observable, + currentState: observable, }); } - setCurrentTask(task: ITask): void { - this.currentTask = task; - } - - setCurrentState(state: string): void { - this.currentState = state; - } - - setLastStateChangeTimestamp(timestamp: Date): void { - this.lastStateChangeTimestamp = timestamp; - } - public static getInstance(): Store { if (!Store.instance) { console.log('Creating new store instance'); @@ -57,15 +54,6 @@ class Store implements IStore { console.log('Returning store instance'); return Store.instance; } - - setSelectedLoginOption(option: string): void { - this.selectedLoginOption = option; - } - - setCurrentTheme(theme: string): void { - this.currentTheme = theme; - } - registerCC(webex?: WithWebex['webex']): Promise { if (webex) { this.cc = webex.cc; @@ -98,10 +86,11 @@ class Store implements IStore { }); } - init(options: InitParams): Promise { + init(options: InitParams, setupEventListeners): Promise { if ('webex' in options) { // If devs decide to go with webex, they will have to listen to the ready event before calling init // This has to be documented + setupEventListeners(options.webex.cc); return this.registerCC(options.webex); } return new Promise((resolve, reject) => { @@ -117,6 +106,7 @@ class Store implements IStore { }); webex.once('ready', () => { + setupEventListeners(webex.cc); clearTimeout(timer); this.registerCC(webex) .then(() => { @@ -130,5 +120,4 @@ class Store implements IStore { } } -const store = Store.getInstance(); -export default store; +export default Store; diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index a5a1fc907..f8ebf1dad 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -1,4 +1,5 @@ import {AgentLogin, IContactCenter, Profile, Team, LogContext} from '@webex/plugin-cc'; +import {ITask} from '@webex/plugin-cc'; type ILogger = { log: (message: string, context?: LogContext) => void; @@ -33,8 +34,35 @@ interface IStore { idleCodes: IdleCode[]; agentId: string; logger: ILogger; - registerCC(webex: WithWebex['webex']): Promise; - init(params: InitParams): Promise; + wrapupCodes: IWrapupCode[]; + currentTask: ITask; + incomingTask: ITask; + taskList: ITask[]; + isAgentLoggedIn: boolean; + deviceType: string; + wrapupRequired: boolean; + currentState: string; + lastStateChangeTimestamp: Date; + showMultipleLoginAlert: boolean; + currentTheme: string; + init(params: InitParams,callback:any): Promise; + registerCC(webex?: WithWebex['webex']): Promise; +} + + +interface IStoreWrapper extends IStore{ + store: IStore; + setCurrentTask(task: any): void; + setWrapupRequired(value: boolean): void; + setTaskList(taskList: ITask[]): void; + setIncomingTask(task: ITask): void; + setDeviceType(option: string): void; + setCurrentState(state: string): void; + setLastStateChangeTimestamp(timestamp: Date): void; + setShowMultipleLoginAlert(value: boolean): void; + setCurrentTheme(theme: string): void; + setIsAgentLoggedIn(value: boolean): void; + setWrapupCodes(wrapupCodes: IWrapupCode[]): void; } interface IWrapupCode { @@ -43,6 +71,30 @@ interface IWrapupCode { } +enum TASK_EVENTS { +TASK_INCOMING = 'task:incoming', +TASK_ASSIGNED = 'task:assigned', +TASK_MEDIA = 'task:media', +TASK_HOLD = 'task:hold', +TASK_UNHOLD = 'task:unhold', +TASK_CONSULT = 'task:consult', +TASK_CONSULT_END = 'task:consultEnd', +TASK_CONSULT_ACCEPT = 'task:consultAccepted', +TASK_PAUSE = 'task:pause', +TASK_RESUME = 'task:resume', +TASK_END = 'task:end', +TASK_WRAPUP = 'task:wrapup', +TASK_REJECT= 'task:rejected', +TASK_HYDRATE = 'task:hydrate', +} // TODO: remove this once cc sdk exports this enum + + +// Events that are received on the contact center SDK +enum CC_EVENTS{ + AGENT_MULTI_LOGIN = 'agent:multiLogin', + AGENT_STATE_CHANGE = 'agent:stateChange', +} + export type { IContactCenter, Profile, @@ -53,5 +105,14 @@ export type { InitParams, IStore, ILogger, - IWrapupCode + IWrapupCode, + IStoreWrapper +} + +export { + CC_EVENTS, + TASK_EVENTS } + + + diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts new file mode 100644 index 000000000..d09bba8a7 --- /dev/null +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -0,0 +1,256 @@ +import {IStoreWrapper, IStore, InitParams, TASK_EVENTS, CC_EVENTS, IWrapupCode, WithWebex} from './store.types'; +import {ITask} from '@webex/plugin-cc'; +import Store from './store'; +import {runInAction} from 'mobx'; + +class StoreWrapper implements IStoreWrapper { + store: IStore; + + constructor() { + this.store = Store.getInstance(); + } + + // Proxy all methods and properties of the original store + get teams() { + return this.store.teams; + } + get loginOptions() { + return this.store.loginOptions; + } + get cc() { + return this.store.cc; + } + get logger() { + return this.store.logger; + } + get idleCodes() { + return this.store.idleCodes; + } + get agentId() { + return this.store.agentId; + } + + get deviceType() { + return this.store.deviceType; + } + get wrapupCodes() { + return this.store.wrapupCodes; + } + get currentTask() { + return this.store.currentTask; + } + get isAgentLoggedIn() { + return this.store.isAgentLoggedIn; + } + get taskList() { + return this.store.taskList; + } + + get incomingTask() { + return this.store.incomingTask; + } + + get wrapupRequired() { + return this.store.wrapupRequired; + } + + get currentState() { + return this.store.currentState; + } + + get lastStateChangeTimestamp() { + return this.store.lastStateChangeTimestamp; + } + + get showMultipleLoginAlert() { + return this.store.showMultipleLoginAlert; + } + + get currentTheme() { + return this.store.currentTheme; + } + + setCurrentTheme = (theme: string): void => { + this.store.currentTheme = theme; + }; + + setShowMultipleLoginAlert = (value: boolean): void => { + this.store.showMultipleLoginAlert = value; + }; + + setDeviceType = (option: string): void => { + this.store.deviceType = option; + }; + + setCurrentState = (state: string): void => { + this.store.currentState = state; + }; + + setLastStateChangeTimestamp = (timestamp: Date): void => { + this.store.lastStateChangeTimestamp = timestamp; + }; + + setIsAgentLoggedIn = (value: boolean): void => { + this.store.isAgentLoggedIn = value; + }; + + setWrapupRequired = (value: boolean): void => { + this.store.wrapupRequired = value; + }; + + setCurrentTask = (task: ITask): void => { + this.store.currentTask = task; + }; + + setIncomingTask = (task: ITask): void => { + this.store.incomingTask = task; + }; + + setTaskList = (taskList: ITask[]): void => { + this.store.taskList = taskList; + }; + + setWrapupCodes = (wrapupCodes: IWrapupCode[]): void => { + this.store.wrapupCodes = wrapupCodes; + }; + + init(options: InitParams): Promise { + return this.store.init(options, this.setupIncomingTaskHandler); + } + + registerCC = (webex?: WithWebex['webex']) => { + return this.store.registerCC(webex); + }; + + handleTaskRemove = (taskId: string) => { + // Remove the task from the taskList + const taskToRemove = this.store.taskList.find((task) => task.data.interactionId === taskId); + if (taskToRemove) { + taskToRemove.off(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned(taskId)); + taskToRemove.off(TASK_EVENTS.TASK_END, ({wrapupRequired}: {wrapupRequired: boolean}) => + this.handleTaskEnd(taskToRemove, wrapupRequired) + ); + taskToRemove.off(TASK_EVENTS.TASK_REJECT, () => this.handleTaskRemove(taskId)); + } + const updateTaskList = this.store.taskList.filter((task) => task.data.interactionId !== taskId); + + runInAction(() => { + this.setTaskList(updateTaskList); + this.setWrapupRequired(false); + + // Remove the task from currentTask or incomingTask if it is the same task + if (this.store.currentTask?.data.interactionId === taskId) { + this.setCurrentTask(null); + } + + if (this.store.incomingTask?.data.interactionId === taskId) { + this.setIncomingTask(null); + } + }); + }; + + handleTaskEnd = (task: ITask, wrapupRequired: boolean) => { + // TODO: SDK needs to send only 1 event on end : https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-615785 + if (task.data.interaction.state === 'connected') { + this.setWrapupRequired(true); + return; + } else if (task.data.interaction.state !== 'connected' && this.store.wrapupRequired !== true) { + this.handleTaskRemove(task.data.interactionId); + } + }; + + handleTaskAssigned = (task: ITask) => () => { + runInAction(() => { + this.setCurrentTask(task); + this.setIncomingTask(null); + }); + }; + + handleIncomingTask = (task: ITask) => { + this.setIncomingTask(task); + if (this.store.taskList.some((t) => t.data.interactionId === task.data.interactionId)) { + // Task already present in the taskList + return; + } + + // Attach event listeners to the task + task.on(TASK_EVENTS.TASK_END, ({wrapupRequired}: {wrapupRequired: boolean}) => { + this.handleTaskEnd(task, wrapupRequired); + }); + + // When we receive TASK_ASSIGNED the task was accepted by the agent and we need wrap up + task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned(task)); + + // When we receive TASK_REJECT sdk changes the agent status + // When we receive TASK_REJECT that means the task was not accepted by the agent and we wont need wrap up + task.on(TASK_EVENTS.TASK_REJECT, () => this.handleTaskRemove(task.data.interactionId)); + + this.setTaskList([...this.store.taskList, task]); + }; + + handleStateChange = (data) => { + if (data && typeof data === 'object' && data.type === 'AgentStateChangeSuccess') { + const DEFAULT_CODE = '0'; // Default code when no aux code is present + this.setCurrentState(data.auxCodeId?.trim() !== '' ? data.auxCodeId : DEFAULT_CODE); + + const startTime = data.lastStateChangeTimestamp; + this.setLastStateChangeTimestamp(new Date(startTime)); + } + }; + + handleMultiLoginCloseSession = (data) => { + if (data && typeof data === 'object' && data.type === 'AgentMultiLoginCloseSession') { + this.setShowMultipleLoginAlert(true); + } + }; + + handleTaskHydrate = (task: ITask) => { + task.on(TASK_EVENTS.TASK_END, ({wrapupRequired}: {wrapupRequired: boolean}) => { + this.handleTaskEnd(task, wrapupRequired); + }); + + // When we receive TASK_ASSIGNED the task was accepted by the agent and we need wrap up + task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned(task)); + + // When we receive TASK_REJECT sdk changes the agent status + // When we receive TASK_REJECT that means the task was not accepted by the agent and we wont need wrap up + task.on(TASK_EVENTS.TASK_REJECT, () => this.handleTaskRemove(task.data.interactionId)); + + if (!this.store.taskList.some((t) => t.data.interactionId === task.data.interactionId)) { + this.setTaskList([...this.store.taskList, task]); + } + + this.setCurrentTask(task); + + const {interaction, agentId} = task.data; + const {state, isTerminated, participants} = interaction; + + // Update call control states + if (isTerminated) { + // wrapup + const wrapupRequired = state === 'wrapUp' && !participants[agentId].isWrappedUp; + this.setWrapupRequired(wrapupRequired); + + return; + } + }; + + setupIncomingTaskHandler = (ccSDK: any) => { + ccSDK.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); + + ccSDK.on(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); + ccSDK.on(CC_EVENTS.AGENT_MULTI_LOGIN, this.handleMultiLoginCloseSession); + ccSDK.on(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + + return () => { + ccSDK.off(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); + ccSDK.off(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); + ccSDK.off(CC_EVENTS.AGENT_MULTI_LOGIN, this.handleMultiLoginCloseSession); + ccSDK.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + }; + }; +} + +// Create and export a single instance of the wrapper +const storeWrapper = new StoreWrapper(); +export default storeWrapper; diff --git a/packages/contact-center/store/tests/store.ts b/packages/contact-center/store/tests/store.ts index be97facb1..d6ff9b745 100644 --- a/packages/contact-center/store/tests/store.ts +++ b/packages/contact-center/store/tests/store.ts @@ -27,11 +27,11 @@ jest.mock('webex', () => ({ describe('Store', () => { let mockWebex; + let storeInstance; beforeEach(() => { - // Reset teams and loginOptions before each test since store is a singleton - store.teams = []; - store.loginOptions = []; + // Reset store values before each test since store is a singleton + storeInstance = store.getInstance(); mockWebex = Webex.init(); jest.useFakeTimers(); // Use fake timers for testing setTimeout }); @@ -41,11 +41,26 @@ describe('Store', () => { }); it('should initialize with default values', () => { - expect(store.teams).toEqual([]); - expect(store.loginOptions).toEqual([]); - expect(store.isAgentLoggedIn).toBe(false); - expect(store.deviceType).toBe(''); - expect(makeAutoObservable).toHaveBeenCalledWith(store, {cc: expect.any(Function), currentTask: expect.any(Object)}); + expect(storeInstance.teams).toEqual([]); + expect(storeInstance.loginOptions).toEqual([]); + expect(storeInstance.idleCodes).toEqual([]); + expect(storeInstance.agentId).toBe(''); + expect(storeInstance.wrapupCodes).toEqual([]); + expect(storeInstance.incomingTask).toBeNull(); + expect(storeInstance.currentTask).toBeNull(); + expect(storeInstance.isAgentLoggedIn).toBe(false); + expect(storeInstance.deviceType).toBe(''); + expect(storeInstance.taskList).toEqual([]); + expect(storeInstance.wrapupRequired).toBe(false); + + expect(makeAutoObservable).toHaveBeenCalledWith(storeInstance, { + cc: expect.any(Function), + currentState: expect.any(Object), + currentTask: expect.any(Object), + incomingTask: expect.any(Object), + taskList: expect.any(Object), + wrapupRequired: expect.any(Object), + }); }); describe('registerCC', () => { @@ -63,16 +78,16 @@ describe('Store', () => { }; mockWebex.cc.register.mockResolvedValue(mockResponse); - await store.registerCC(mockWebex); + await storeInstance.registerCC(mockWebex); - expect(store.teams).toEqual(mockResponse.teams); - expect(store.loginOptions).toEqual(mockResponse.loginVoiceOptions); - expect(store.idleCodes).toEqual(mockResponse.idleCodes); - expect(store.agentId).toEqual(mockResponse.agentId); - expect(store.isAgentLoggedIn).toEqual(mockResponse.isAgentLoggedIn); - expect(store.deviceType).toEqual(mockResponse.deviceType); - expect(store.currentState).toEqual(mockResponse.lastStateAuxCodeId); - expect(store.lastStateChangeTimestamp).toEqual(date); + expect(storeInstance.teams).toEqual(mockResponse.teams); + expect(storeInstance.loginOptions).toEqual(mockResponse.loginVoiceOptions); + expect(storeInstance.idleCodes).toEqual(mockResponse.idleCodes); + expect(storeInstance.agentId).toEqual(mockResponse.agentId); + expect(storeInstance.isAgentLoggedIn).toEqual(mockResponse.isAgentLoggedIn); + expect(storeInstance.deviceType).toEqual(mockResponse.deviceType); + expect(storeInstance.currentState).toEqual(mockResponse.lastStateAuxCodeId); + expect(storeInstance.lastStateChangeTimestamp).toEqual(date); }); it('should log an error on failed register', async () => { @@ -80,26 +95,43 @@ describe('Store', () => { mockWebex.cc.register.mockRejectedValue(mockError); try { - await store.registerCC(mockWebex); + await storeInstance.registerCC(mockWebex); } catch (error) { expect(error).toEqual(mockError); - expect(store.logger.error).toHaveBeenCalledWith('Error registering contact center: Error: Register failed', { - method: 'registerCC', - module: 'cc-store#store.ts', - }); + expect(storeInstance.logger.error).toHaveBeenCalledWith( + 'Error registering contact center: Error: Register failed', + { + method: 'registerCC', + module: 'cc-store#store.ts', + } + ); } }); }); describe('init', () => { + it('should call eventListenerCallback ', async () => { + const eventListenerCallback = jest.fn(); + const initParams = {webex: mockWebex}; + + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); + Webex.init.mockClear(); + + await storeInstance.init(initParams, eventListenerCallback); + + expect(eventListenerCallback).toHaveBeenCalled(); + expect(storeInstance.registerCC).toHaveBeenCalledWith(mockWebex); + expect(Webex.init).not.toHaveBeenCalled(); + }); + it('should call registerCC if webex is in options', async () => { const initParams = {webex: mockWebex}; - jest.spyOn(store, 'registerCC').mockResolvedValue(); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); Webex.init.mockClear(); - await store.init(initParams); + await storeInstance.init(initParams, jest.fn()); - expect(store.registerCC).toHaveBeenCalledWith(mockWebex); + expect(storeInstance.registerCC).toHaveBeenCalledWith(mockWebex); expect(Webex.init).not.toHaveBeenCalled(); }); @@ -108,15 +140,15 @@ describe('Store', () => { webexConfig: {anyConfig: true}, access_token: 'fake_token', }; - jest.spyOn(store, 'registerCC').mockResolvedValue(); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); - await store.init(initParams); + await storeInstance.init(initParams, jest.fn()); expect(Webex.init).toHaveBeenCalledWith({ config: initParams.webexConfig, credentials: {access_token: initParams.access_token}, }); - expect(store.registerCC).toHaveBeenCalledWith(expect.any(Object)); + expect(storeInstance.registerCC).toHaveBeenCalledWith(expect.any(Object)); }); it('should reject the promise if registerCC fails in init method', async () => { @@ -125,9 +157,9 @@ describe('Store', () => { access_token: 'fake_token', }; - jest.spyOn(store, 'registerCC').mockRejectedValue(new Error('registerCC failed')); + jest.spyOn(storeInstance, 'registerCC').mockRejectedValue(new Error('registerCC failed')); - await expect(store.init(initParams)).rejects.toThrow('registerCC failed'); + await expect(storeInstance.init(initParams, jest.fn())).rejects.toThrow('registerCC failed'); }); it('should reject the promise if Webex SDK fails to initialize', async () => { @@ -138,9 +170,9 @@ describe('Store', () => { mockShouldCallback = false; - jest.spyOn(store, 'registerCC').mockResolvedValue(); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); - const initPromise = store.init(initParams); + const initPromise = storeInstance.init(initParams); jest.runAllTimers(); // Fast-forward the timers to simulate timeout diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts new file mode 100644 index 000000000..ad5496796 --- /dev/null +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -0,0 +1,612 @@ +import {act, waitFor} from '@testing-library/react'; +import {CC_EVENTS, TASK_EVENTS} from '../src/store.types'; +import storeWrapper from '../src/storeEventsWrapper'; +import {ITask} from '@webex/plugin-cc'; + +jest.mock('../src/store', () => ({ + getInstance: jest.fn().mockReturnValue({ + teams: 'mockTeams', + loginOptions: 'mockLoginOptions', + cc: { + on: jest.fn(), + off: jest.fn(), + }, + logger: 'mockLogger', + idleCodes: 'mockIdleCodes', + agentId: 'mockAgentId', + wrapupCodes: 'mockWrapupCodes', + currentTask: 'mockCurrentTask', + isAgentLoggedIn: false, + deviceType: 'mockDeviceType', + taskList: 'mockTaskList', + incomingTask: 'mockIncomingTask', + wrapupRequired: 'mockWrapupRequired', + currentState: 'mockCurrentState', + lastStateChangeTimestamp: 'mockLastStateChangeTimestamp', + showMultipleLoginAlert: 'mockShowMultipleLoginAlert', + currentTheme: 'mockCurrentTheme', + setShowMultipleLoginAlert: jest.fn(), + setCurrentState: jest.fn(), + setLastStateChangeTimestamp: jest.fn(), + setDeviceType: jest.fn(), + init: jest.fn().mockResolvedValue({}), + setIncomingTask: jest.fn(), + setCurrentTask: jest.fn(), + setTaskList: jest.fn(), + setWrapupRequired: jest.fn(), + setCurrentTheme: jest.fn(), + setIsAgentLoggedIn: jest.fn(), + }), +})); + +describe('storeEventsWrapper', () => { + describe('storeEventsWrapper Proxies', () => { + it('should proxy teams', () => { + expect(storeWrapper.teams).toBe('mockTeams'); + }); + + it('should proxy loginOptions', () => { + expect(storeWrapper.loginOptions).toBe('mockLoginOptions'); + }); + + it('should proxy logger', () => { + expect(storeWrapper.logger).toBe('mockLogger'); + }); + + it('should proxy idleCodes', () => { + expect(storeWrapper.idleCodes).toBe('mockIdleCodes'); + }); + + it('should proxy agentId', () => { + expect(storeWrapper.agentId).toBe('mockAgentId'); + }); + + it('should proxy deviceType', () => { + expect(storeWrapper.deviceType).toBe('mockDeviceType'); + }); + + it('should proxy wrapupCodes', () => { + expect(storeWrapper.wrapupCodes).toBe('mockWrapupCodes'); + }); + + it('should proxy currentTask', () => { + expect(storeWrapper.currentTask).toBe('mockCurrentTask'); + }); + + it('should proxy isAgentLoggedIn', () => { + expect(storeWrapper.isAgentLoggedIn).toBe(false); + }); + + it('should proxy deviceType', () => { + expect(storeWrapper.deviceType).toBe('mockDeviceType'); + }); + + it('should proxy taskList', () => { + expect(storeWrapper.taskList).toBe('mockTaskList'); + }); + + it('should proxy incomingTask', () => { + expect(storeWrapper.incomingTask).toBe('mockIncomingTask'); + }); + + it('should proxy wrapupRequired', () => { + expect(storeWrapper.wrapupRequired).toBe('mockWrapupRequired'); + }); + + it('should proxy currentState', () => { + expect(storeWrapper.currentState).toBe('mockCurrentState'); + }); + + it('should proxy lastStateChangeTimestamp', () => { + expect(storeWrapper.lastStateChangeTimestamp).toBe('mockLastStateChangeTimestamp'); + }); + + it('should proxy showMultipleLoginAlert', () => { + expect(storeWrapper.showMultipleLoginAlert).toBe('mockShowMultipleLoginAlert'); + + storeWrapper.setShowMultipleLoginAlert(true); + expect(storeWrapper['store'].showMultipleLoginAlert).toBe(true); + }); + + it('should setShowMultipleLoginAlert', () => { + expect(storeWrapper.setShowMultipleLoginAlert).toBeInstanceOf(Function); + + storeWrapper.setShowMultipleLoginAlert(true); + expect(storeWrapper['store'].showMultipleLoginAlert).toBe(true); + }); + + it('should setCurrentState', () => { + expect(storeWrapper.setCurrentState).toBeInstanceOf(Function); + + storeWrapper.setCurrentState('newState'); + expect(storeWrapper['store'].currentState).toBe('newState'); + }); + + it('should setLastStateChangeTimestamp', () => { + expect(storeWrapper.setLastStateChangeTimestamp).toBeInstanceOf(Function); + + const timestamp = new Date(); + storeWrapper.setLastStateChangeTimestamp(timestamp); + expect(storeWrapper['store'].lastStateChangeTimestamp).toBe(timestamp); + }); + + it('should currentTheme', () => { + expect(storeWrapper.currentTheme).toBe('mockCurrentTheme'); + }); + + it('should setCurrentTheme', () => { + expect(storeWrapper.setCurrentTheme).toBeInstanceOf(Function); + + storeWrapper.setCurrentTheme('newTheme'); + expect(storeWrapper['store'].currentTheme).toBe('newTheme'); + }); + + it('should setIsAgentLoggedIn', () => { + expect(storeWrapper.setIsAgentLoggedIn).toBeInstanceOf(Function); + + storeWrapper.setIsAgentLoggedIn(false); + expect(storeWrapper['store'].isAgentLoggedIn).toBe(false); + }); + + it('should setIsAgentLoggedIn', () => { + expect(storeWrapper.setIsAgentLoggedIn).toBeInstanceOf(Function); + + storeWrapper.setIsAgentLoggedIn(false); + expect(storeWrapper['store'].isAgentLoggedIn).toBe(false); + }); + + it('should setWrapupCodes', () => { + const mockCodes = [{code1: 'code1'}, {code2: 'code2'}]; + expect(storeWrapper.setWrapupCodes).toBeInstanceOf(Function); + + storeWrapper.setWrapupCodes(mockCodes); + expect(storeWrapper['store'].wrapupCodes).toBe(mockCodes); + }); + }); + + describe('storeEventsWrapper', () => { + const mockTask: ITask = ({ + data: { + interactionId: 'interaction1', + interaction: { + state: 'connected', + }, + }, + on: jest.fn(), + off: jest.fn(), + } as unknown) as ITask; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize the store and set up incoming task handler', async () => { + const options = {someOption: 'value'}; + await storeWrapper.init(options); + + expect(storeWrapper['store'].init).toHaveBeenCalledWith(options, expect.any(Function)); + }); + + it('should handle incoming task', () => { + const setIncomingTaskSpy = jest.spyOn(storeWrapper, 'setIncomingTask'); + const setTaskListSpy = jest.spyOn(storeWrapper, 'setTaskList'); + + storeWrapper['store'].taskList = []; + storeWrapper.handleIncomingTask(mockTask); + + expect(setIncomingTaskSpy).toHaveBeenCalledWith(mockTask); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(setTaskListSpy).toHaveBeenCalledWith([mockTask]); + }); + + it('should handle task assignment', () => { + const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); + const setIncomingTaskSpy = jest.spyOn(storeWrapper, 'setIncomingTask'); + + // Why is this, this way? + const handleTaskAssigned = storeWrapper.handleTaskAssigned(mockTask); + handleTaskAssigned(); + + expect(setCurrentTaskSpy).toHaveBeenCalledWith(mockTask); + expect(setIncomingTaskSpy).toHaveBeenCalledWith(null); + }); + + it('should handle task removal', () => { + const setTaskListSpy = jest.spyOn(storeWrapper, 'setTaskList'); + const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); + const setIncomingTaskSpy = jest.spyOn(storeWrapper, 'setIncomingTask'); + const setWrapupRequiredSpy = jest.spyOn(storeWrapper, 'setWrapupRequired'); + + storeWrapper['store'].taskList = [mockTask]; + storeWrapper['store'].currentTask = mockTask; + storeWrapper['store'].incomingTask = mockTask; + + storeWrapper.handleTaskRemove(mockTask.data.interactionId); + + expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + + expect(setTaskListSpy).toHaveBeenCalledWith([]); + expect(setCurrentTaskSpy).toHaveBeenCalledWith(null); + expect(setIncomingTaskSpy).toHaveBeenCalledWith(null); + expect(setWrapupRequiredSpy).toHaveBeenCalledWith(false); + }); + + it('should handle task removal when no task is present', () => { + storeWrapper['store'].taskList = []; + storeWrapper['store'].currentTask = null; + storeWrapper['store'].incomingTask = null; + storeWrapper.handleTaskRemove(mockTask.data.interactionId); + + expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + const setTaskListSpy = jest.spyOn(storeWrapper, 'setTaskList'); + const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); + const setIncomingTaskSpy = jest.spyOn(storeWrapper, 'setIncomingTask'); + const setWrapupRequiredSpy = jest.spyOn(storeWrapper, 'setWrapupRequired'); + + storeWrapper.handleTaskRemove(mockTask.data.interactionId); + + expect(setTaskListSpy).toHaveBeenCalledWith([]); + expect(setCurrentTaskSpy).not.toHaveBeenCalledWith(null); + expect(setIncomingTaskSpy).not.toHaveBeenCalledWith(null); + expect(setWrapupRequiredSpy).toHaveBeenCalledWith(false); + }); + + it('should handle task end', () => { + const setWrapupRequiredSpy = jest.spyOn(storeWrapper, 'setWrapupRequired'); + + storeWrapper.handleTaskEnd(mockTask, true); + expect(setWrapupRequiredSpy).toHaveBeenCalledWith(true); + + storeWrapper.handleTaskEnd(mockTask, false); + expect(setWrapupRequiredSpy).toHaveBeenCalledWith(true); + }); + + it('should set selected login option', () => { + const setDeviceTypeSpy = jest.spyOn(storeWrapper, 'setDeviceType'); + const option = 'newLoginOption'; + + storeWrapper.setDeviceType(option); + + expect(setDeviceTypeSpy).toHaveBeenCalledWith(option); + }); + }); + + describe('storeEventsWrapper events reactions', () => { + const mockTask: ITask = ({ + data: { + interactionId: 'interaction1', + interaction: { + state: 'connected', + }, + }, + on: jest.fn(), + off: jest.fn(), + } as unknown) as ITask; + + const options = {someOption: 'value'}; + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + it('should initialize the store and set up event handlers', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + + await storeWrapper.init(options); + + expect(storeWrapper['store'].init).toHaveBeenCalledWith(options, expect.any(Function)); + + expect(cc.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); + expect(cc.on).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATE_CHANGE, expect.any(Function)); + expect(cc.on).toHaveBeenCalledWith(CC_EVENTS.AGENT_MULTI_LOGIN, expect.any(Function)); + }); + + it('should handle task:incoming event ', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + + await storeWrapper.init(options); + storeWrapper['store'].taskList = []; + + act(() => { + storeWrapper['store'].cc.on.mock.calls[0][1](mockTask); + }); + + waitFor(() => { + expect(storeWrapper['store'].setIncomingTask).toHaveBeenCalledWith(mockTask); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(storeWrapper['store'].setTaskList).toHaveBeenCalledWith([mockTask]); + }); + }); + + it('should handle task:end event with wrapupRequired', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + + await storeWrapper.init(options); + storeWrapper['store'].taskList = []; + + // Incoming task stage: a task has just reached the agent + act(() => { + storeWrapper['cc'].on.mock.calls[0][1](mockTask); + }); + + waitFor(() => { + expect(storeWrapper['store'].setIncomingTask).toHaveBeenCalledWith(mockTask); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(storeWrapper['store'].setTaskList).toHaveBeenCalledWith([mockTask]); + }); + + // The call is answered and the task is assigned to the agent + act(() => { + mockTask.on.mock.calls[1][1](); + }); + + waitFor(() => { + // The task is assigned to the agent + expect(storeWrapper['store'].setCurrentTask).toHaveBeenCalledWith(mockTask); + expect(storeWrapper['store'].setIncomingTask).toHaveBeenCalledWith(null); + }); + + // Task end stage: the task is completed + act(() => { + storeWrapper['store'].taskList = [mockTask]; + mockTask.on.mock.calls[0][1]({wrapupRequired: true}); + }); + + waitFor(() => { + expect(storeWrapper['store'].setWrapupRequired).toHaveBeenCalledWith(true); + expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + }); + }); + + it('should not add a duplicate task if the same task is present in store', () => { + const setIncomingTaskSpy = jest.spyOn(storeWrapper, 'setIncomingTask'); + storeWrapper['store'].taskList = [mockTask]; + storeWrapper.handleIncomingTask(mockTask); + + expect(setIncomingTaskSpy).toHaveBeenCalledWith(mockTask); + expect(mockTask.on).not.toHaveBeenCalledWith; + }); + + it('should handle task assignment', () => { + jest.spyOn(storeWrapper, 'setCurrentTask'); + jest.spyOn(storeWrapper, 'setIncomingTask'); + + const handleTaskAssigned = storeWrapper.handleTaskAssigned(mockTask); + handleTaskAssigned(); + + expect(storeWrapper.setCurrentTask).toHaveBeenCalledWith(mockTask); + expect(storeWrapper.setIncomingTask).toHaveBeenCalledWith(null); + }); + + it('should handle task removal', () => { + const setTaskListSpy = jest.spyOn(storeWrapper, 'setTaskList'); + const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); + const setIncomingTaskSpy = jest.spyOn(storeWrapper, 'setIncomingTask'); + const setWrapupRequiredSpy = jest.spyOn(storeWrapper, 'setWrapupRequired'); + + storeWrapper['store'].taskList = [mockTask]; + storeWrapper['store'].currentTask = mockTask; + storeWrapper['store'].incomingTask = mockTask; + storeWrapper.handleTaskRemove(mockTask.data.interactionId); + + expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + + expect(setTaskListSpy).toHaveBeenCalledWith([]); + expect(setCurrentTaskSpy).toHaveBeenCalledWith(null); + expect(setIncomingTaskSpy).toHaveBeenCalledWith(null); + expect(setWrapupRequiredSpy).toHaveBeenCalledWith(false); + }); + + it('should handle task end', () => { + jest.spyOn(storeWrapper, 'setWrapupRequired'); + storeWrapper.handleTaskEnd(mockTask, true); + + expect(storeWrapper.setWrapupRequired).toHaveBeenCalledWith(true); + + storeWrapper.handleTaskEnd(mockTask, false); + + expect(storeWrapper.setWrapupRequired).toHaveBeenCalledWith(true); + }); + + it('should handle task end when call is not connected', () => { + jest.spyOn(storeWrapper, 'setWrapupRequired'); + mockTask.data.interaction.state = 'new'; + storeWrapper['store'].wrapupRequired = false; + storeWrapper.handleTaskEnd(mockTask, true); + + expect(storeWrapper.setWrapupRequired).toHaveBeenCalledWith(false); + + storeWrapper.handleTaskEnd(mockTask, false); + + expect(storeWrapper.setWrapupRequired).toHaveBeenCalledWith(false); + + storeWrapper['store'].wrapupRequired = true; + storeWrapper.handleTaskEnd(mockTask, true); + + expect(storeWrapper.setWrapupRequired).toHaveBeenCalledWith(false); + }); + + it('should set selected login option', () => { + jest.spyOn(storeWrapper, 'setDeviceType'); + const option = 'newLoginOption'; + storeWrapper.setDeviceType(option); + + expect(storeWrapper.setDeviceType).toHaveBeenCalledWith(option); + }); + + it('should handle multilogin session modal with in correct data', async () => { + jest.spyOn(storeWrapper, 'setShowMultipleLoginAlert'); + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + + act(() => { + cc.on.mock.calls[2][1]({}); + }); + + expect(storeWrapper.setShowMultipleLoginAlert).not.toHaveBeenCalledWith(true); + }); + + it('should handle multilogin session modal with correct data', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + jest.spyOn(storeWrapper, 'setShowMultipleLoginAlert'); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + act(() => { + storeWrapper['store'].cc.on.mock.calls[2][1]({type: 'AgentMultiLoginCloseSession'}); + }); + + expect(storeWrapper.setShowMultipleLoginAlert).toHaveBeenCalledWith(true); + }); + + it('should set selected login option', () => { + const option = 'newLoginOption'; + jest.spyOn(storeWrapper, 'setDeviceType'); + storeWrapper.setDeviceType(option); + + expect(storeWrapper.setDeviceType).toHaveBeenCalledWith(option); + }); + + it('should handle state change event with incorrect data', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + jest.spyOn(storeWrapper, 'setCurrentState'); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + act(() => { + cc.on.mock.calls[1][1]({}); + }); + + expect(storeWrapper.setCurrentState).not.toHaveBeenCalledWith(); + }); + + it('should handle state change event with correct data and emplty auxcodeId', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + jest.spyOn(storeWrapper, 'setCurrentState'); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + act(() => { + cc.on.mock.calls[1][1]({type: 'AgentStateChangeSuccess', auxCodeId: ''}); + }); + + expect(storeWrapper.setCurrentState).toHaveBeenCalledWith('0'); + }); + + it('should handle state change event with correct data', async () => { + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + jest.spyOn(storeWrapper, 'setCurrentState'); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + act(() => { + cc.on.mock.calls[1][1]({type: 'AgentStateChangeSuccess', auxCodeId: 'available'}); + }); + + expect(storeWrapper.setCurrentState).toHaveBeenCalledWith('available'); + }); + + it('should handle hydrating the store with correct data', async () => { + const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); + const setTaskListSpy = jest.spyOn(storeWrapper, 'setTaskList'); + const setWrapupRequiredSpy = jest.spyOn(storeWrapper, 'setWrapupRequired'); + + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + storeWrapper['store'].taskList = []; + + const mockTask = { + data: { + interaction: { + isTerminated: true, + state: 'wrapUp', + participants: { + agent1: { + isWrappedUp: false, + }, + }, + }, + agentId: 'agent1', + }, + on: jest.fn(), + }; + + act(() => { + cc.on.mock.calls[3][1](mockTask); + }); + + expect(setCurrentTaskSpy).toHaveBeenCalledWith(mockTask); + expect(setTaskListSpy).toHaveBeenCalledWith([mockTask]); + expect(setWrapupRequiredSpy).toHaveBeenCalledWith(true); + }); + + it('should handle hydrating the store with correct data', async () => { + const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); + const setTaskListSpy = jest.spyOn(storeWrapper, 'setTaskList'); + const setWrapupRequiredSpy = jest.spyOn(storeWrapper, 'setWrapupRequired'); + + const cc = storeWrapper['store'].cc; + storeWrapper['store'].init = jest.fn().mockReturnValue(storeWrapper.setupIncomingTaskHandler(cc)); + + const options = {someOption: 'value'}; + await storeWrapper.init(options); + storeWrapper['store'].taskList = []; + + const mockTask = { + data: { + interaction: { + isTerminated: false, + state: 'wrapUp', + participants: { + agent1: { + isWrappedUp: false, + }, + }, + }, + agentId: 'agent1', + }, + on: jest.fn(), + }; + + act(() => { + cc.on.mock.calls[3][1](mockTask); + }); + + expect(setCurrentTaskSpy).toHaveBeenCalledWith(mockTask); + expect(setTaskListSpy).toHaveBeenCalledWith([mockTask]); + expect(setWrapupRequiredSpy).not.toHaveBeenCalledWith(); + }); + + it('should return a function to remove event listeners on cc object', () => { + const cc = storeWrapper['store'].cc; + const removeListeners = storeWrapper.setupIncomingTaskHandler(cc); + + removeListeners(); + + expect(storeWrapper['cc'].off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); + expect(storeWrapper['cc'].off).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, expect.any(Function)); + expect(storeWrapper['cc'].off).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATE_CHANGE, expect.any(Function)); + expect(storeWrapper['cc'].off).toHaveBeenCalledWith(CC_EVENTS.AGENT_MULTI_LOGIN, expect.any(Function)); + }); + }); +}); diff --git a/packages/contact-center/task/package.json b/packages/contact-center/task/package.json index 96614379e..d673fc363 100644 --- a/packages/contact-center/task/package.json +++ b/packages/contact-center/task/package.json @@ -21,7 +21,7 @@ "dependencies": { "@webex/cc-store": "workspace:*", "mobx-react-lite": "^4.1.0", - "webex": "3.7.0-wxcc.12" + "webex": "3.7.0-wxcc.15" }, "devDependencies": { "@babel/core": "7.25.2", diff --git a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx index 328272161..8b1b84033 100644 --- a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx +++ b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {WrapupCodes} from '@webex/cc-store'; import {CallControlPresentationalProps} from '../task.types'; @@ -12,6 +12,20 @@ function CallControlPresentational(props: CallControlPresentationalProps) { const {currentTask, audioRef, toggleHold, toggleRecording, endCall, wrapupCall, wrapupCodes, wrapupRequired} = props; + useEffect(() => { + if (!currentTask || !currentTask.data || !currentTask.data.interaction) return; + + const {interaction, mediaResourceId} = currentTask.data; + const {media, callProcessingDetails} = interaction; + const isHold = media && media[mediaResourceId] && media[mediaResourceId].isHold; + setIsHeld(isHold); + + if (callProcessingDetails) { + const {isPaused} = callProcessingDetails; + setIsRecording(!isPaused); + } + }, [currentTask]); + const handletoggleHold = () => { toggleHold(!isHeld); setIsHeld(!isHeld); @@ -25,7 +39,8 @@ function CallControlPresentational(props: CallControlPresentationalProps) { const handleWrapupCall = () => { if (selectedWrapupReason && selectedWrapupId) { wrapupCall(selectedWrapupReason, selectedWrapupId); - setSelectedWrapupReason(''); + setSelectedWrapupReason(null); + setSelectedWrapupId(null); } }; @@ -51,7 +66,7 @@ function CallControlPresentational(props: CallControlPresentationalProps) { - diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index aeda9372a..398dce1da 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -7,11 +7,10 @@ import {CallControlProps} from '../task.types'; import CallControlPresentational from './call-control.presentational'; const CallControlComponent: React.FunctionComponent = ({onHoldResume, onEnd, onWrapUp}) => { - const {logger, currentTask, wrapupCodes} = store; - + const {logger, currentTask, wrapupCodes, wrapupRequired} = store; const result = useCallControl({currentTask, onHoldResume, onEnd, onWrapUp, logger}); - return ; + return ; }; const CallControl = observer(CallControlComponent); diff --git a/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx b/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx index 228dc4388..8353d69e7 100644 --- a/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx +++ b/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx @@ -120,9 +120,8 @@ const styles: {[key: string]: React.CSSProperties} = { }; const IncomingTaskPresentational: React.FunctionComponent = (props) => { - const {incomingTask, accept, decline, isBrowser, isAnswered} = props; - - if (!incomingTask || isAnswered) { + const {incomingTask, accept, decline, isBrowser} = props; + if (!incomingTask) { return <>; // hidden component } diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index ff6d19eab..9cf0116e9 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -7,9 +7,8 @@ import IncomingTaskPresentational from './incoming-task.presentational'; import {IncomingTaskProps} from '../task.types'; const IncomingTaskComponent: React.FunctionComponent = ({onAccepted, onDeclined}) => { - const {cc, selectedLoginOption, logger} = store; - - const result = useIncomingTask({cc, onAccepted, onDeclined, selectedLoginOption, logger}); + const {cc, deviceType, incomingTask, logger} = store; + const result = useIncomingTask({cc, incomingTask, onAccepted, onDeclined, deviceType, logger}); const props = { ...result, diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx index e87487b9a..a023e008f 100644 --- a/packages/contact-center/task/src/TaskList/index.tsx +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -6,9 +6,9 @@ import TaskListPresentational from './task-list.presentational'; import {useTaskList} from '../helper'; const TaskListComponent: React.FunctionComponent = () => { - const {cc, currentTask, selectedLoginOption, logger} = store; + const {cc, taskList, currentTask, deviceType, logger} = store; - const result = useTaskList({cc, selectedLoginOption, logger}); + const result = useTaskList({cc, deviceType, logger, taskList}); const props = { ...result, currentTask, diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index a30510692..2229246c9 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -1,13 +1,12 @@ -import {useState, useEffect, useCallback, useRef} from 'react'; +import {useEffect, useCallback, useRef} from 'react'; import {ITask} from '@webex/plugin-cc'; import store from '@webex/cc-store'; import {TASK_EVENTS, useCallControlProps, UseTaskListProps, UseTaskProps} from './task.types'; // Hook for managing the task list export const useTaskList = (props: UseTaskListProps) => { - const {cc, selectedLoginOption, onTaskAccepted, onTaskDeclined, logger} = props; - const [taskList, setTaskList] = useState([]); - const isBrowser = selectedLoginOption === 'BROWSER'; + const {deviceType, onTaskAccepted, onTaskDeclined, logger, taskList} = props; + const isBrowser = deviceType === 'BROWSER'; const logError = (message: string, method: string) => { logger.error(message, { @@ -16,35 +15,6 @@ export const useTaskList = (props: UseTaskListProps) => { }); }; - const handleTaskRemoved = useCallback((taskId: string) => { - setTaskList((prev) => { - const taskToRemove = prev.find((task) => task.data.interactionId === taskId); - - if (taskToRemove) { - // Clean up listeners on the task - taskToRemove.off(TASK_EVENTS.TASK_END, () => handleTaskRemoved(taskId)); - } - - return prev.filter((task) => task.data.interactionId !== taskId); - }); - }, []); - - const handleIncomingTask = useCallback( - (task: ITask) => { - setTaskList((prev) => { - if (prev.some((t) => t.data.interactionId === task.data.interactionId)) { - return prev; - } - - // Attach event listeners to the task - task.on(TASK_EVENTS.TASK_END, () => handleTaskRemoved(task.data.interactionId)); - - return [...prev, task]; - }); - }, - [handleTaskRemoved] // Include handleTaskRemoved as a dependency - ); - const acceptTask = (task: ITask) => { const taskId = task?.data.interactionId; if (!taskId) return; @@ -52,7 +22,6 @@ export const useTaskList = (props: UseTaskListProps) => { task .accept(taskId) .then(() => { - store.setCurrentTask(task); onTaskAccepted && onTaskAccepted(task); }) .catch((error: Error) => { @@ -68,32 +37,18 @@ export const useTaskList = (props: UseTaskListProps) => { .decline(taskId) .then(() => { onTaskDeclined && onTaskDeclined(task); - store.setCurrentTask(null); }) .catch((error: Error) => { logError(`Error declining task: ${error}`, 'declineTask'); }); }; - useEffect(() => { - // Listen for incoming tasks globally - cc.on(TASK_EVENTS.TASK_INCOMING, handleIncomingTask); - - return () => { - cc.off(TASK_EVENTS.TASK_INCOMING, handleIncomingTask); - }; - }, [cc, handleIncomingTask]); - return {taskList, acceptTask, declineTask, isBrowser}; }; -// Hook for managing the current task export const useIncomingTask = (props: UseTaskProps) => { - const {cc, onAccepted, onDeclined, selectedLoginOption, logger} = props; - const [incomingTask, setIncomingTask] = useState(null); - const [isAnswered, setIsAnswered] = useState(false); - const [isEnded, setIsEnded] = useState(false); - const isBrowser = selectedLoginOption === 'BROWSER'; + const {cc, onAccepted, onDeclined, deviceType, incomingTask, logger} = props; + const isBrowser = deviceType === 'BROWSER'; const logError = (message: string, method: string) => { logger.error(message, { @@ -102,41 +57,6 @@ export const useIncomingTask = (props: UseTaskProps) => { }); }; - const handleTaskAssigned = useCallback(() => { - // Task that are accepted using anything other than browser should be populated - // in the store only when we receive task assigned event - if (!isBrowser) store.setCurrentTask(incomingTask); - setIsAnswered(true); - }, [incomingTask]); - - const handleTaskEnded = useCallback(() => { - setIsEnded(true); - setIncomingTask(null); - }, []); - - const handleIncomingTask = useCallback((task: ITask) => { - setIncomingTask(task); - setIsAnswered(false); - setIsEnded(false); - }, []); - - useEffect(() => { - cc.on(TASK_EVENTS.TASK_INCOMING, handleIncomingTask); - - if (incomingTask) { - incomingTask.on(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); - incomingTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); - } - - return () => { - cc.off(TASK_EVENTS.TASK_INCOMING, handleIncomingTask); - if (incomingTask) { - incomingTask.off(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); - incomingTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); - } - }; - }, [cc, incomingTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded]); - const accept = () => { const taskId = incomingTask?.data.interactionId; if (!taskId) return; @@ -144,9 +64,6 @@ export const useIncomingTask = (props: UseTaskProps) => { incomingTask .accept(taskId) .then(() => { - // Task that are accepted using BROWSER should be populated - // in the store when we accept the call - store.setCurrentTask(incomingTask); onAccepted && onAccepted(); }) .catch((error: Error) => { @@ -161,8 +78,6 @@ export const useIncomingTask = (props: UseTaskProps) => { incomingTask .decline(taskId) .then(() => { - setIncomingTask(null); - store.setCurrentTask(null); onDeclined && onDeclined(); }) .catch((error: Error) => { @@ -172,8 +87,6 @@ export const useIncomingTask = (props: UseTaskProps) => { return { incomingTask, - isAnswered, - isEnded, accept, decline, isBrowser, @@ -182,7 +95,6 @@ export const useIncomingTask = (props: UseTaskProps) => { export const useCallControl = (props: useCallControlProps) => { const {currentTask, onHoldResume, onEnd, onWrapUp, logger} = props; - const [wrapupRequired, setWrapupRequired] = useState(false); const audioRef = useRef(null); // Ref for the audio element const logError = (message: string, method: string) => { @@ -192,10 +104,6 @@ export const useCallControl = (props: useCallControlProps) => { }); }; - const handleTaskEnded = useCallback(({wrapupRequired}: {wrapupRequired: boolean}) => { - setWrapupRequired(wrapupRequired); - }, []); - const handleTaskMedia = useCallback( (track) => { if (audioRef.current) { @@ -207,14 +115,13 @@ export const useCallControl = (props: useCallControlProps) => { useEffect(() => { if (!currentTask) return; + // Call control only event for WebRTC calls currentTask.on(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); - currentTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); return () => { currentTask.off(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); - currentTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); }; - }, [currentTask, handleTaskEnded]); + }, [currentTask]); const toggleHold = (hold: boolean) => { if (hold) { @@ -237,10 +144,6 @@ export const useCallControl = (props: useCallControlProps) => { }; const toggleRecording = (pause: boolean) => { - const logLocation = { - module: 'widget-cc-task#helper.ts', - method: 'useCallControl#pauseResumeRecording', - }; if (pause) { currentTask.pauseRecording().catch((error: Error) => { logError(`Error pausing recording: ${error}`, 'toggleRecording'); @@ -267,9 +170,8 @@ export const useCallControl = (props: useCallControlProps) => { currentTask .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) .then(() => { - setWrapupRequired(false); - store.setCurrentTask(null); if (onWrapUp) onWrapUp(); + store.handleTaskRemove(currentTask.data.interactionId); }) .catch((error: Error) => { logError(`Error wrapping up call: ${error}`, 'wrapupCall'); @@ -283,6 +185,5 @@ export const useCallControl = (props: useCallControlProps) => { toggleHold, toggleRecording, wrapupCall, - wrapupRequired, }; }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 55fb1c2ad..578865e30 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -78,7 +78,7 @@ export interface TaskProps { /** * Selected login option */ - selectedLoginOption: string; + deviceType: string; /** * List of tasks @@ -91,15 +91,15 @@ export interface TaskProps { logger: ILogger; } -export type UseTaskProps = Pick; -export type UseTaskListProps = Pick< +export type UseTaskProps = Pick< TaskProps, - 'cc' | 'selectedLoginOption' | 'onTaskAccepted' | 'onTaskDeclined' | 'logger' + 'cc' | 'incomingTask' | 'onAccepted' | 'onDeclined' | 'deviceType' | 'logger' >; -export type IncomingTaskPresentationalProps = Pick< +export type UseTaskListProps = Pick< TaskProps, - 'incomingTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' + 'cc' | 'taskList' | 'deviceType' | 'onTaskAccepted' | 'onTaskDeclined' | 'logger' >; +export type IncomingTaskPresentationalProps = Pick; export type IncomingTaskProps = Pick; export type TaskListProps = Pick; @@ -138,17 +138,17 @@ export interface ControlProps { /** * Function to handle hold/resume actions. */ - onHoldResume: () => void; + onHoldResume?: () => void; /** * Function to handle ending the task. */ - onEnd: () => void; + onEnd?: () => void; /** * Function to handle wrapping up the task. */ - onWrapUp: () => void; + onWrapUp?: () => void; /** * Logger instance for logging purposes. diff --git a/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx b/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx index 6e0c4831a..c3d576bab 100644 --- a/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx +++ b/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx @@ -107,4 +107,33 @@ describe('CallControlPresentational', () => { const wrapupButton = screen.getByText('Wrap Up'); expect(wrapupButton).not.toBeDisabled(); }); + + it('sets the isHeld and isRecording state correctly based on store data', () => { + const mockTask = { + data: { + interaction: { + media: { + mediaResourceId1: { + isHold: true, + }, + }, + callProcessingDetails: { + isPaused: false, + }, + }, + mediaResourceId: 'mediaResourceId1', + agentId: 'agent1', + wrapupRequired: false, + }, + }; + + const propsWithCurrentTask = {...defaultProps, currentTask: mockTask}; + render(); + + const holdButton = screen.getByText('Resume'); + expect(holdButton).not.toBeDisabled(); + + const pauseButton = screen.getByText('Pause Recording'); + expect(pauseButton).not.toBeDisabled(); + }); }); diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 7f467d65b..70f455a87 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -8,7 +8,7 @@ import '@testing-library/jest-dom'; // Mock the store jest.mock('@webex/cc-store', () => ({ cc: {}, - selectedLoginOption: 'BROWSER', + deviceType: 'BROWSER', wrapupCodes: [], logger: {}, currentTask: { diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx index ff71c9972..fdf6bbda6 100644 --- a/packages/contact-center/task/tests/IncomingTask/index.tsx +++ b/packages/contact-center/task/tests/IncomingTask/index.tsx @@ -8,7 +8,7 @@ import '@testing-library/jest-dom'; // Mock the store jest.mock('@webex/cc-store', () => ({ cc: {}, - selectedLoginOption: 'BROWSER', + deviceType: 'BROWSER', })); const onAcceptedCb = jest.fn(); @@ -35,7 +35,7 @@ describe('IncomingTask Component', () => { // Assert that the useIncomingTask hook is called with the correct arguments expect(useIncomingTaskSpy).toHaveBeenCalledWith({ cc: store.cc, - selectedLoginOption: store.selectedLoginOption, + deviceType: store.deviceType, onAccepted: onAcceptedCb, onDeclined: onDeclinedCb, }); diff --git a/packages/contact-center/task/tests/TaskList/index.tsx b/packages/contact-center/task/tests/TaskList/index.tsx index 4fe02fa7a..19afcedb0 100644 --- a/packages/contact-center/task/tests/TaskList/index.tsx +++ b/packages/contact-center/task/tests/TaskList/index.tsx @@ -14,7 +14,7 @@ jest.mock('../../src/TaskList/task-list.presentational', () => { // Mock `@webex/cc-store`. jest.mock('@webex/cc-store', () => ({ cc: {}, - selectedLoginOption: 'BROWSER', + deviceType: 'BROWSER', onAccepted: jest.fn(), onDeclined: jest.fn(), })); @@ -49,6 +49,6 @@ describe('TaskList Component', () => { expect(TaskListPresentational).toHaveBeenCalledWith({taskList: taskListMock}, {}); // Verify that `useTaskList` is called with the correct arguments. - expect(helper.useTaskList).toHaveBeenCalledWith({cc: store.cc, selectedLoginOption: 'BROWSER'}); + expect(helper.useTaskList).toHaveBeenCalledWith({cc: store.cc, deviceType: 'BROWSER'}); }); }); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index ab8948979..3f514bc7e 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -21,7 +21,7 @@ const taskMock = { const onAccepted = jest.fn(); const onDeclined = jest.fn(); -const onTaskAccepted = jest.fn(); +const onTaskAccepted = jest.fn().mockImplementation(() => {}); const onTaskDeclined = jest.fn(); const logger = { @@ -34,52 +34,79 @@ describe('useIncomingTask Hook', () => { logger.error.mockRestore(); }); - it('should register task events for the current task', async () => { + it('should call onAccepted if it is provided', async () => { const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) + useIncomingTask({ + cc: ccMock, + incomingTask: taskMock, + onAccepted: onTaskAccepted, + onDeclined: onTaskDeclined, + deviceType: 'BROWSER', + logger, + }) ); act(() => { - ccMock.on.mock.calls[0][1](taskMock); + result.current.accept(); }); await waitFor(() => { - expect(taskMock.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); - expect(taskMock.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + expect(onTaskAccepted).toHaveBeenCalled(); }); // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); }); - it('should not call onAccepted if it is not provided', async () => { + it('should call onDeclined if it is provided', async () => { const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted: null, onDeclined: null, selectedLoginOption: 'BROWSER', logger}) + useIncomingTask({ + cc: ccMock, + incomingTask: taskMock, + onAccepted: onTaskAccepted, + onDeclined: onTaskDeclined, + deviceType: 'BROWSER', + logger, + }) ); act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - act(() => { - result.current.accept(); + result.current.decline(); }); await waitFor(() => { - expect(onAccepted).not.toHaveBeenCalled(); + expect(onTaskDeclined).toHaveBeenCalled(); }); // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); }); - it('should not call onDeclined if it is not provided', async () => { + it('should return if there is no taskId for incoming task', async () => { + const noIdTask = { + data: {}, + accept: jest.fn(), + decline: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }; const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted: null, onDeclined: null, selectedLoginOption: 'BROWSER', logger}) + useIncomingTask({ + cc: ccMock, + incomingTask: noIdTask, + onAccepted: onTaskAccepted, + onDeclined: onTaskDeclined, + deviceType: 'BROWSER', + logger, + }) ); act(() => { - ccMock.on.mock.calls[0][1](taskMock); + result.current.accept(); + }); + + await waitFor(() => { + expect(onTaskAccepted).not.toHaveBeenCalled(); }); act(() => { @@ -87,27 +114,52 @@ describe('useIncomingTask Hook', () => { }); await waitFor(() => { - expect(onDeclined).not.toHaveBeenCalled(); + expect(onTaskDeclined).not.toHaveBeenCalled(); + }); + }); + + it('should not call onAccepted if it is not provided', async () => { + const {result} = renderHook(() => + useIncomingTask({ + cc: ccMock, + incomingTask: taskMock, + onAccepted: null, + onDeclined: null, + deviceType: 'BROWSER', + logger, + }) + ); + + act(() => { + result.current.accept(); + }); + + await waitFor(() => { + expect(onAccepted).not.toHaveBeenCalled(); }); // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); }); - it('should clean up task events on task change or unmount', async () => { - const {result, unmount} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) + it('should not call onDeclined if it is not provided', async () => { + const {result} = renderHook(() => + useIncomingTask({ + cc: ccMock, + incomingTask: taskMock, + onAccepted: null, + onDeclined: null, + deviceType: 'BROWSER', + logger, + }) ); act(() => { - ccMock.on.mock.calls[0][1](taskMock); + result.current.decline(); }); - unmount(); - await waitFor(() => { - expect(taskMock.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); - expect(ccMock.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); + expect(onDeclined).not.toHaveBeenCalled(); }); // Ensure no errors are logged @@ -122,13 +174,9 @@ describe('useIncomingTask Hook', () => { }; const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted, selectedLoginOption: 'BROWSER', logger}) + useIncomingTask({cc: ccMock, incomingTask: failingTask, onAccepted, deviceType: 'BROWSER', logger}) ); - act(() => { - ccMock.on.mock.calls[0][1](failingTask); - }); - act(() => { result.current.accept(); }); @@ -153,13 +201,9 @@ describe('useIncomingTask Hook', () => { }; const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onDeclined, selectedLoginOption: 'BROWSER', logger}) + useIncomingTask({cc: ccMock, incomingTask: failingTask, onDeclined, deviceType: 'BROWSER', logger}) ); - act(() => { - ccMock.on.mock.calls[0][1](failingTask); - }); - act(() => { result.current.decline(); }); @@ -178,13 +222,16 @@ describe('useIncomingTask Hook', () => { }); describe('useTaskList Hook', () => { + const mockTaskList = [taskMock, taskMock]; afterEach(() => { jest.clearAllMocks(); logger.error.mockRestore(); }); it('should call onTaskAccepted callback when provided', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, selectedLoginOption: '', onTaskAccepted, logger})); + const {result} = renderHook(() => + useTaskList({cc: ccMock, deviceType: '', onTaskAccepted, logger, taskList: mockTaskList}) + ); act(() => { result.current.acceptTask(taskMock); @@ -198,8 +245,42 @@ describe('useTaskList Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + it('should return if not task is passed while calling acceptTask', async () => { + // This test is purely to improve the coverage report, as the acceptTask function cannot be called without a task + const {result} = renderHook(() => + useTaskList({cc: ccMock, deviceType: '', onTaskAccepted, logger, taskList: mockTaskList}) + ); + + act(() => { + // @ts-ignore + result.current.acceptTask(); + }); + + await waitFor(() => { + expect(onTaskAccepted).not.toHaveBeenCalledWith(taskMock); + }); + }); + + it('should return if not task is passed while calling acceptTask', async () => { + // This test is purely to improve the coverage report, as the acceptTask function cannot be called without a task + const {result} = renderHook(() => + useTaskList({cc: ccMock, deviceType: '', onTaskDeclined, logger, taskList: mockTaskList}) + ); + + act(() => { + // @ts-ignore + result.current.declineTask(); + }); + + await waitFor(() => { + expect(onTaskDeclined).not.toHaveBeenCalledWith(taskMock); + }); + }); + it('should call onTaskDeclined callback when provided', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, selectedLoginOption: '', onTaskDeclined, logger})); + const {result} = renderHook(() => + useTaskList({cc: ccMock, deviceType: '', onTaskDeclined, logger, taskList: mockTaskList}) + ); act(() => { result.current.declineTask(taskMock); @@ -221,13 +302,9 @@ describe('useTaskList Hook', () => { }; const {result} = renderHook(() => - useTaskList({cc: ccMock, onTaskAccepted, selectedLoginOption: 'BROWSER', logger}) + useTaskList({cc: ccMock, onTaskAccepted, deviceType: 'BROWSER', logger, taskList: mockTaskList}) ); - act(() => { - ccMock.on.mock.calls[0][1](failingTask); - }); - act(() => { result.current.acceptTask(failingTask); }); @@ -252,13 +329,9 @@ describe('useTaskList Hook', () => { }; const {result} = renderHook(() => - useTaskList({cc: ccMock, onTaskDeclined, selectedLoginOption: 'BROWSER', logger}) + useTaskList({cc: ccMock, onTaskDeclined, deviceType: 'BROWSER', logger, taskList: mockTaskList}) ); - act(() => { - ccMock.on.mock.calls[0][1](failingTask); - }); - act(() => { result.current.declineTask(failingTask); }); @@ -275,24 +348,16 @@ describe('useTaskList Hook', () => { }); }); - it('should add tasks to the list on TASK_INCOMING event', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); - - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - await waitFor(() => { - expect(result.current.taskList).toContain(taskMock); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - it('should not call onTaskAccepted if it is not provided', async () => { const {result} = renderHook(() => - useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null, logger, selectedLoginOption: ''}) + useTaskList({ + cc: ccMock, + onTaskAccepted: null, + onTaskDeclined: null, + logger, + deviceType: '', + taskList: mockTaskList, + }) ); act(() => { @@ -309,7 +374,14 @@ describe('useTaskList Hook', () => { it('should not call onTaskDeclined if it is not provided', async () => { const {result} = renderHook(() => - useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null, logger, selectedLoginOption: ''}) + useTaskList({ + cc: ccMock, + onTaskAccepted: null, + onTaskDeclined: null, + logger, + deviceType: '', + taskList: mockTaskList, + }) ); act(() => { @@ -323,130 +395,13 @@ describe('useTaskList Hook', () => { // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); }); - - it('should remove a task from the list when it ends', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); - - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_END)?.[1](); - }); - - await waitFor(() => { - expect(result.current.taskList).not.toContain(taskMock); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - - it('should update an existing task in the list', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); - - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - const updatedTask = {...taskMock, data: {interactionId: 'interaction1', status: 'updated'}}; - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_ASSIGNED)?.[1](updatedTask); - }); - - await waitFor(() => {}); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - - it('should deduplicate tasks by interactionId', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); - - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - ccMock.on.mock.calls[0][1](taskMock); - }); - - await waitFor(() => { - expect(result.current.taskList.length).toBe(1); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - - describe('useIncomingTask Hook - Task Events', () => { - afterEach(() => { - jest.clearAllMocks(); - logger.error.mockRestore(); - }); - - it('should set isAnswered to true when task is assigned', async () => { - const {result} = renderHook(() => - useIncomingTask({ - cc: ccMock, - onAccepted, - onDeclined, - selectedLoginOption: 'BROWSER', - logger, - selectedLoginOption: '', - }) - ); - - // Simulate task being assigned - act(() => { - ccMock.on.mock.calls[0][1](taskMock); // Simulate incoming task - }); - - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_ASSIGNED)?.[1](); // Trigger task assigned - }); - - await waitFor(() => { - expect(result.current.isAnswered).toBe(true); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - - it('should set isEnded to true and clear currentTask when task ends', async () => { - const {result} = renderHook(() => - useIncomingTask({ - cc: ccMock, - onAccepted, - onDeclined, - selectedLoginOption: 'BROWSER', - logger, - selectedLoginOption: '', - }) - ); - - // Simulate task being assigned - act(() => { - ccMock.on.mock.calls[0][1](taskMock); // Simulate incoming task - }); - - // Simulate task ending - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_END)?.[1](); // Trigger task end - }); - - await waitFor(() => { - expect(result.current.isEnded).toBe(true); - expect(result.current.incomingTask).toBeNull(); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - }); }); describe('useCallControl', () => { const mockCurrentTask = { + data: { + interactionId: 'someMockInteractionId', + }, on: jest.fn(), off: jest.fn(), hold: jest.fn(() => Promise.resolve()), @@ -484,33 +439,35 @@ describe('useCallControl', () => { logger.error.mockRestore(); }); - it('should set up and clean up event listeners on currentTask', () => { - renderHook(() => + it('should not call any call backs if callbacks are not provided', async () => { + mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); + + const {result} = renderHook(() => useCallControl({ currentTask: mockCurrentTask, - onHoldResume: mockOnHoldResume, - onEnd: mockOnEnd, - onWrapUp: mockOnWrapUp, logger: mockLogger, }) ); - expect(mockCurrentTask.on).toHaveBeenCalledWith('task:end', expect.any(Function)); + await act(async () => { + await result.current.toggleHold(true); + }); - // Cleanup on unmount - const {unmount} = renderHook(() => - useCallControl({ - currentTask: mockCurrentTask, - onHoldResume: mockOnHoldResume, - onEnd: mockOnEnd, - onWrapUp: mockOnWrapUp, - logger: mockLogger, - }) - ); + await act(async () => { + await result.current.toggleHold(false); + }); - unmount(); + await act(async () => { + await result.current.endCall(); + }); - expect(mockCurrentTask.off).toHaveBeenCalledWith('task:end', expect.any(Function)); + await act(async () => { + await result.current.wrapupCall('Wrap reason', '123'); + }); + + expect(mockOnHoldResume).not.toHaveBeenCalled(); + expect(mockOnEnd).not.toHaveBeenCalled(); + expect(mockOnWrapUp).not.toHaveBeenCalled(); }); it('should call holdResume with hold=true and handle success', async () => { @@ -571,7 +528,7 @@ describe('useCallControl', () => { expect(mockLogger.error).toHaveBeenCalledWith('Error holding call: Error: Hold error', expect.any(Object)); }); - it('should log an error if hold fails', async () => { + it('should log an error if resume fails', async () => { mockCurrentTask.resume.mockRejectedValueOnce(new Error('Resume error')); const {result} = renderHook(() => @@ -610,23 +567,6 @@ describe('useCallControl', () => { expect(mockOnEnd).toHaveBeenCalled(); }); - it('should update wrapupRequired on TASK_END event', async () => { - const {result} = renderHook(() => - useCallControl({ - currentTask: mockCurrentTask, - onHoldResume: mockOnHoldResume, - onEnd: mockOnEnd, - onWrapUp: mockOnWrapUp, - logger: mockLogger, - }) - ); - - await act(async () => { - await mockCurrentTask.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_END)?.[1]({wrapupRequired: true}); - }); - expect(result.current.wrapupRequired).toBe(true); - }); - it('should call endCall and handle failure', async () => { mockCurrentTask.end.mockRejectedValueOnce(new Error('End error')); const {result} = renderHook(() => @@ -644,10 +584,11 @@ describe('useCallControl', () => { }); expect(mockCurrentTask.end).toHaveBeenCalled(); + expect(mockOnEnd).not.toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalledWith('Error ending call: Error: End error', expect.any(Object)); }); - it('should call wrapupCall and handle success', async () => { + it('should call wrapupCall ', async () => { const {result} = renderHook(() => useCallControl({ currentTask: mockCurrentTask, @@ -659,10 +600,10 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.wrapupCall('Wrap reason', 123); + await result.current.wrapupCall('Wrap reason', '123'); }); - expect(mockCurrentTask.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: 123}); + expect(mockCurrentTask.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: '123'}); expect(mockOnWrapUp).toHaveBeenCalled(); }); @@ -782,7 +723,7 @@ describe('useCallControl', () => { ); act(() => { - mockCurrentTask.on.mock.calls[0][1](mockAudio); + mockCurrentTask.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_MEDIA)?.[1](mockAudio); }); await waitFor(() => { @@ -897,4 +838,47 @@ describe('useCallControl', () => { // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); }); + + it('should not add media events if task is not available', async () => { + const mockAudioElement = {current: {srcObject: null}}; + jest.spyOn(React, 'useRef').mockReturnValue(mockAudioElement); + + renderHook(() => + useCallControl({ + currentTask: undefined, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + // Ensure no event handler is set + expect(taskMock.on).not.toHaveBeenCalled(); + }); + + it('should test undefined audioRef.current branch', async () => { + // This test is to improve the coverage + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + result.current.audioRef.current = undefined; + const mockTrack = new MediaStreamTrack(); + + act(() => { + const taskAssignedCallback = mockCurrentTask.on.mock.calls.find( + (call) => call[0] === TASK_EVENTS.TASK_MEDIA + )?.[1]; + + if (taskAssignedCallback) { + taskAssignedCallback(mockTrack); + } + }); + }); }); diff --git a/packages/contact-center/user-state/package.json b/packages/contact-center/user-state/package.json index 577efc684..755b6fcd3 100644 --- a/packages/contact-center/user-state/package.json +++ b/packages/contact-center/user-state/package.json @@ -16,7 +16,7 @@ "build": "yarn run -T tsc", "build:src": "yarn run clean:dist && webpack", "build:watch": "webpack --watch", - "test:unit": "jest" + "test:unit": "jest --coverage" }, "dependencies": { "@webex/cc-components": "workspace:*", diff --git a/packages/contact-center/user-state/src/constants.ts b/packages/contact-center/user-state/src/constants.ts deleted file mode 100644 index b9332cb21..000000000 --- a/packages/contact-center/user-state/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const AGENT_STATE_CHANGE = 'agent:stateChange'; diff --git a/packages/contact-center/user-state/src/helper.ts b/packages/contact-center/user-state/src/helper.ts index 18f2a094e..5ee0e3fce 100644 --- a/packages/contact-center/user-state/src/helper.ts +++ b/packages/contact-center/user-state/src/helper.ts @@ -1,6 +1,5 @@ import {useState, useEffect, useRef} from 'react'; // TODO: Export & Import this AGENT_STATE_CHANGE constant from SDK -import {AGENT_STATE_CHANGE} from './constants'; import store from '@webex/cc-store'; export const useUserState = ({idleCodes, agentId, cc, currentState, lastStateChangeTimestamp}) => { const [isSettingAgentStatus, setIsSettingAgentStatus] = useState(false); @@ -36,33 +35,31 @@ export const useUserState = ({idleCodes, agentId, cc, currentState, lastStateCha workerRef.current.onmessage = (event) => { setElapsedTime(event.data); }; + }, []); - const handleStateChange = (data) => { - if (data && typeof data === 'object' && data.type === 'AgentStateChangeSuccess') { - const DEFAULT_CODE = '0'; // Default code when no aux code is present - store.setCurrentState(data.auxCodeId?.trim() !== '' ? data.auxCodeId : DEFAULT_CODE); - - const startTime = data.lastStateChangeTimestamp; - store.setLastStateChangeTimestamp(new Date(startTime)); + useEffect(() => { + if (workerRef.current) { + workerRef.current.terminate(); + const blob = new Blob([workerScript], {type: 'application/javascript'}); + const workerUrl = URL.createObjectURL(blob); + workerRef.current = new Worker(workerUrl); + workerRef.current.onmessage = (event) => { + setElapsedTime(event.data); + }; + if (lastStateChangeTimestamp) { + const timeNow = new Date(); + const elapsed = Math.floor(Math.abs(timeNow.getTime() - lastStateChangeTimestamp.getTime()) / 1000); + setElapsedTime(elapsed); + workerRef.current.postMessage({type: 'reset', startTime: lastStateChangeTimestamp.getTime()}); + } else { + workerRef.current.postMessage({type: 'start', startTime: Date.now()}); } - }; - - cc.on(AGENT_STATE_CHANGE, handleStateChange); + } return () => { workerRef.current?.terminate(); - cc.off(AGENT_STATE_CHANGE, handleStateChange); }; - }, []); - - useEffect(() => { - if (workerRef.current && lastStateChangeTimestamp) { - const timeNow = new Date(); - const elapsed = Math.floor(Math.abs(timeNow.getTime() - lastStateChangeTimestamp.getTime()) / 1000); - setElapsedTime(elapsed); - workerRef.current.postMessage({type: 'reset', startTime: lastStateChangeTimestamp.getTime()}); - } - }, [lastStateChangeTimestamp]); + }, [currentState]); const setAgentStatus = (selectedCode) => { const {auxCodeId, state} = { diff --git a/packages/contact-center/user-state/tests/helper.ts b/packages/contact-center/user-state/tests/helper.ts index 6dcb667b6..7412a76d8 100644 --- a/packages/contact-center/user-state/tests/helper.ts +++ b/packages/contact-center/user-state/tests/helper.ts @@ -51,8 +51,6 @@ describe('useUserState Hook', () => { elapsedTime: 0, currentState: '0', }); - - expect(mockCC.on).toHaveBeenCalledTimes(1); }); it('should increment elapsedTime every second', () => { @@ -71,23 +69,6 @@ describe('useUserState Hook', () => { expect(result.current.elapsedTime).toBe(3); }); - it('should reset elapsedTime when lastStateChangeTimestamp changes', async () => { - const newTimestamp = new Date(); - const {result, rerender} = renderHook( - ({timestamp}) => - useUserState({idleCodes, agentId, cc: mockCC, currentState: '0', lastStateChangeTimestamp: timestamp}), - {initialProps: {timestamp: new Date(Date.now() - 5000)}} - ); - - expect(result.current.elapsedTime).toBe(5); - - rerender({timestamp: newTimestamp}); - - await waitFor(() => { - expect(result.current.elapsedTime).toBe(0); - }); - }); - it('should handle setAgentStatus correctly and update state', async () => { mockCC.setAgentState.mockResolvedValueOnce({data: {auxCodeId: '2', lastStateChangeTimestamp: new Date()}}); const {result} = renderHook(() => @@ -103,50 +84,23 @@ describe('useUserState Hook', () => { }); }); - it('should handle errors from setAgentStatus and revert state', async () => { - mockCC.setAgentState.mockRejectedValueOnce(new Error('Error setting agent status')); + it('should handle setAgentStatus correctly and update state', async () => { + mockCC.setAgentState.mockResolvedValueOnce({data: {auxCodeId: '2', lastStateChangeTimestamp: new Date()}}); const {result} = renderHook(() => useUserState({idleCodes, agentId, cc: mockCC, currentState: '0', lastStateChangeTimestamp: new Date()}) ); - await act(async () => { - await result.current.setAgentStatus(idleCodes[1]); - }); - - await waitFor(() => { - expect(result.current.errorMessage).toBe('Error: Error setting agent status'); - }); - }); - - it('should handle agent state change events correctly', async () => { - const {result} = renderHook(() => - useUserState({idleCodes, agentId, cc: mockCC, currentState: 'auxCodeId', lastStateChangeTimestamp: new Date()}) - ); - - const handler = mockCC.on.mock.calls[0][1]; - act(() => { - handler({type: 'AgentStateChangeSuccess', auxCodeId: '123'}); + result.current.setAgentStatus(idleCodes[0]); }); await waitFor(() => { - expect(store.setCurrentState).toHaveBeenCalledWith('123'); + expect(store.setCurrentState).toHaveBeenCalledWith('2'); }); }); - it('should cleanup event listener on unmount', () => { - const {unmount} = renderHook(() => - useUserState({idleCodes, agentId, cc: mockCC, currentState: '0', lastStateChangeTimestamp: new Date()}) - ); - - unmount(); - expect(mockCC.off).toHaveBeenCalledTimes(1); - }); - - it('should update store with new current state when agent status changes', async () => { - mockCC.setAgentState.mockResolvedValueOnce({ - data: {auxCodeId: '2', lastStateChangeTimestamp: new Date().toISOString()}, - }); + it('should handle errors from setAgentStatus and revert state', async () => { + mockCC.setAgentState.mockRejectedValueOnce(new Error('Error setting agent status')); const {result} = renderHook(() => useUserState({idleCodes, agentId, cc: mockCC, currentState: '0', lastStateChangeTimestamp: new Date()}) ); @@ -156,25 +110,7 @@ describe('useUserState Hook', () => { }); await waitFor(() => { - expect(store.setCurrentState).toHaveBeenCalledWith('2'); - expect(store.setLastStateChangeTimestamp).toHaveBeenCalled(); - }); - }); - - it('should update store when agent state change event occurs', async () => { - const {result} = renderHook(() => - useUserState({idleCodes, agentId, cc: mockCC, currentState: '0', lastStateChangeTimestamp: new Date()}) - ); - - const handler = mockCC.on.mock.calls[0][1]; - - act(() => { - handler({type: 'AgentStateChangeSuccess', auxCodeId: '3', lastStateChangeTimestamp: new Date().toISOString()}); - }); - - await waitFor(() => { - expect(store.setCurrentState).toHaveBeenCalledWith('3'); - expect(store.setLastStateChangeTimestamp).toHaveBeenCalled(); + expect(result.current.errorMessage).toBe('Error: Error setting agent status'); }); }); }); 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 fc06909a0..fd3020592 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -4,6 +4,13 @@ import {ThemeProvider, IconProvider} from '@momentum-design/components/dist/reac function App() { const [isSdkReady, setIsSdkReady] = useState(false); + const [selectedWidgets, setSelectedWidgets] = useState({ + stationLogin: false, + userState: false, + incomingTask: false, + taskList: false, + callControl: false, + }); const [accessToken, setAccessToken] = useState(''); const [isLoggedIn, setIsLoggedIn] = useState(false); const themeCheckboxRef = useRef(null); @@ -66,6 +73,11 @@ function App() { } }; + const handleCheckboxChange = (e) => { + const {name, checked} = e.target; + setSelectedWidgets((prev) => ({...prev, [name]: checked})); + }; + return (
setAccessToken(e.target.value)} />
+ <> +
+ + + + + +
+ {isSdkReady && ( <> - - {isLoggedIn && ( + {selectedWidgets.stationLogin && } + {store.isAgentLoggedIn && ( <> - - - - + {selectedWidgets.userState && } + {selectedWidgets.incomingTask && } + {selectedWidgets.taskList && ( + + )} + {selectedWidgets.callControl && ( + + )} )} diff --git a/widgets-samples/cc/samples-cc-wc-app/app.js b/widgets-samples/cc/samples-cc-wc-app/app.js index a79351786..127304142 100644 --- a/widgets-samples/cc/samples-cc-wc-app/app.js +++ b/widgets-samples/cc/samples-cc-wc-app/app.js @@ -2,26 +2,38 @@ const accessTokenElem = document.getElementById('access_token_elem'); const themeElem = document.getElementById('theme'); const widgetsContainer = document.getElementById('widgets-container'); -const ccStationLogin = document.getElementById('cc-station-login'); + +const ccStationLogin = document.createElement('widget-cc-station-login'); const ccUserState = document.createElement('widget-cc-user-state'); const ccIncomingTask = document.createElement('widget-cc-incoming-task'); const ccTaskList = document.createElement('widget-cc-task-list'); const ccCallControl = document.createElement('widget-cc-call-control'); -const themeProviderElem = document.getElementById('theme-provider-elem'); + +const themeProviderElem = document.getElementById('theme-provider-elem'); + +const stationLoginCheckbox = document.getElementById('stationLoginCheckbox'); +const userStateCheckbox = document.getElementById('userStateCheckbox'); +const incomingTaskCheckbox = document.getElementById('incomingTaskCheckbox'); +const taskListCheckbox = document.getElementById('taskListCheckbox'); +const callControlCheckbox = document.getElementById('callControlCheckbox'); + let isMultiLoginEnabled = false; themeElem.addEventListener('change', () => { - store.setCurrentTheme(themeElem.checked ? 'DARK' : 'LIGHT'); - themeProviderElem.setAttribute('themeclass', themeElem.checked ? 'mds-theme-stable-darkWebex' : 'mds-theme-stable-lightWebex'); + store.setCurrentTheme(themeElem.checked ? 'DARK' : 'LIGHT'); + themeProviderElem.setAttribute( + 'themeclass', + themeElem.checked ? 'mds-theme-stable-darkWebex' : 'mds-theme-stable-lightWebex' + ); }); if (!ccStationLogin && !ccUserState) { - console.error('Failed to find the required elements'); + console.error('Failed to find the required elements'); } -function switchButtonState(){ - const buttonElem = document.querySelector('button'); - buttonElem.disabled = accessTokenElem.value.trim() === ''; +function switchButtonState() { + const buttonElem = document.querySelector('button'); + buttonElem.disabled = accessTokenElem.value.trim() === ''; } function enableMultiLogin() { @@ -29,74 +41,98 @@ function enableMultiLogin() { else isMultiLoginEnabled = true; } -function initWidgets(){ - const webexConfig = { - fedramp: false, - logger: { - level: 'log' - }, - cc: { - allowMultiLogin: isMultiLoginEnabled, - }, - } - store.init({ - webexConfig, - access_token: accessTokenElem.value.trim() - }).then(() => { - ccStationLogin.onLogin = loginSuccess; - ccStationLogin.onLogout = logoutSuccess; - ccIncomingTask.onAccepted = onAccepted; - ccIncomingTask.onDeclined = onDeclined; - ccTaskList.onTaskAccepted = onTaskAccepted; - ccTaskList.onTaskDeclined = onTaskDeclined; - ccCallControl.onHoldResume = onHoldResume; - ccCallControl.onEnd = onEnd; - ccCallControl.onWrapup = onWrapup; +function initWidgets() { + const webexConfig = { + fedramp: false, + logger: { + level: 'log', + }, + cc: { + allowMultiLogin: isMultiLoginEnabled, + }, + }; + store + .init({ + webexConfig, + access_token: accessTokenElem.value.trim(), + }) + .then(() => { + ccStationLogin.onLogin = loginSuccess; + ccStationLogin.onLogout = logoutSuccess; + ccIncomingTask.onAccepted = onAccepted; + ccIncomingTask.onDeclined = onDeclined; + ccTaskList.onTaskAccepted = onTaskAccepted; + ccTaskList.onTaskDeclined = onTaskDeclined; + ccCallControl.onHoldResume = onHoldResume; + ccCallControl.onEnd = onEnd; + ccCallControl.onWrapup = onWrapup; + + if (stationLoginCheckbox.checked) { ccStationLogin.classList.remove('disabled'); - }).catch((error) => { - console.error('Failed to initialize widgets:', error); + widgetsContainer.appendChild(ccStationLogin); + } + if (store.isAgentLoggedIn) { + loginSuccess(); + } else { + console.error('Agent is not logged in! Station Login Widget is required'); + } + }) + .catch((error) => { + console.error('Failed to initialize widgets:', error); }); } -function loginSuccess(){ - console.log('Agent login has been succesful'); +function loginSuccess() { + if (userStateCheckbox.checked) { ccUserState.classList.remove('disabled'); widgetsContainer.appendChild(ccUserState); + } + if (incomingTaskCheckbox.checked) { + ccIncomingTask.classList.remove('disabled'); widgetsContainer.appendChild(ccIncomingTask); + } + if (taskListCheckbox.checked) { + ccTaskList.classList.remove('disabled'); widgetsContainer.appendChild(ccTaskList); + } + if (callControlCheckbox.checked) { + ccCallControl.classList.remove('disabled'); widgetsContainer.appendChild(ccCallControl); + } } -function logoutSuccess(){ - console.log('Agent logout has been succesful'); - ccUserState.classList.add('disabled'); +function logoutSuccess() { + console.log('Agent logout has been succesful'); + ccUserState.classList.add('disabled'); + ccIncomingTask.classList.add('disabled'); + ccTaskList.classList.add('disabled'); + ccCallControl.classList.add('disabled'); } -function onAccepted(){ - console.log('onAccepted Invoked'); -}; +function onAccepted() { + console.log('onAccepted Invoked'); +} -function onDeclined(){ - console.log('onDeclined invoked'); -}; +function onDeclined() { + console.log('onDeclined invoked'); +} -function onTaskAccepted(){ - console.log('onTaskAccepted invoked'); -}; +function onTaskAccepted() { + console.log('onTaskAccepted invoked'); +} -function onTaskDeclined(){ - console.log('onTaskDeclined invoked'); -}; +function onTaskDeclined() { + console.log('onTaskDeclined invoked'); +} function onHoldResume() { - console.log('onHoldResume invoked'); - } - - function onEnd() { - console.log('onEnd invoked'); - } - - function onWrapup() { - console.log('onWrapUp invoked'); - } - \ No newline at end of file + console.log('onHoldResume invoked'); +} + +function onEnd() { + console.log('onEnd invoked'); +} + +function onWrapup() { + console.log('onWrapUp invoked'); +} diff --git a/widgets-samples/cc/samples-cc-wc-app/index.html b/widgets-samples/cc/samples-cc-wc-app/index.html index 074b46b91..1c8d4cfaa 100644 --- a/widgets-samples/cc/samples-cc-wc-app/index.html +++ b/widgets-samples/cc/samples-cc-wc-app/index.html @@ -23,32 +23,34 @@

Contact Center widgets as web-component

spellcheck="false" autocapitalize="off" /> +
+ + + + + +

- Dark Theme + Dark Theme
- Note: The "Enable Multi Login" option must be set before initializing the SDK. Changes - to this setting after SDK initialization will not take effect. Please ensure you configure this option - before clicking the "Init Widgets" button. + Note: The "Enable Multi Login" option must be set before initializing the SDK. Changes to + this setting after SDK initialization will not take effect. Please ensure you configure this option before + clicking the "Init Widgets" button.

Enable Multi Login + /> + Enable Multi Login
-
- -
+
diff --git a/yarn.lock b/yarn.lock index 73b854507..b2a585bdb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5677,9 +5677,9 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.6.0-wxcc.1": - version: 3.6.0-wxcc.1 - resolution: "@webex/calling@npm:3.6.0-wxcc.1" +"@webex/calling@npm:3.7.0-wxcc.1": + version: 3.7.0-wxcc.1 + resolution: "@webex/calling@npm:3.7.0-wxcc.1" dependencies: "@types/platform": "npm:1.3.4" "@webex/internal-media-core": "npm:2.11.1" @@ -5690,7 +5690,7 @@ __metadata: platform: "npm:1.3.6" uuid: "npm:8.3.2" xstate: "npm:4.30.6" - checksum: 10c0/e553d2b376c9c691a2cabb6fbbd4b8e3507506916c81a77b0b4d39f91a222f28216c1ab60edbfc60fbfc74dc701df1ea93f4fec113203ab17e0e79359b19dd8e + checksum: 10c0/9d623cb31697a9976c8f8a4f62bab82d959f78f31b31746bc0a8e4c5cd6645352eae4234565f2028ecf6ce1a476e17d700c37cafcb8096ae091e12a94c865f0c languageName: node linkType: hard @@ -5778,7 +5778,7 @@ __metadata: mobx: "npm:6.13.5" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" - webex: "npm:3.7.0-wxcc.12" + webex: "npm:3.7.0-wxcc.15" webpack: "npm:5.94.0" webpack-cli: "npm:5.1.4" webpack-merge: "npm:6.0.1" @@ -5807,7 +5807,7 @@ __metadata: mobx-react-lite: "npm:^4.1.0" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" - webex: "npm:3.7.0-wxcc.12" + webex: "npm:3.7.0-wxcc.15" webpack: "npm:5.94.0" webpack-cli: "npm:5.1.4" webpack-merge: "npm:6.0.1" @@ -7103,17 +7103,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-cc@npm:3.5.0-wxcc.18": - version: 3.5.0-wxcc.18 - resolution: "@webex/plugin-cc@npm:3.5.0-wxcc.18" +"@webex/plugin-cc@npm:3.5.0-wxcc.21": + version: 3.5.0-wxcc.21 + resolution: "@webex/plugin-cc@npm:3.5.0-wxcc.21" dependencies: "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.6.0-wxcc.1" + "@webex/calling": "npm:3.7.0-wxcc.1" "@webex/internal-plugin-mercury": "npm:3.5.0-wxcc.1" "@webex/webex-core": "npm:3.5.0-wxcc.1" buffer: "npm:6.0.3" jest-html-reporters: "npm:3.0.11" - checksum: 10c0/13b115ad2593d272a41932e2423a502f423240ce4c0c1040327dfef1f7eb12abd49b36916228f6953be0bfe7c966c353d66eb0b5978ea05b1b2a7a57c74aa091 + checksum: 10c0/9f9c71b3084b1e1f1101004abc4b8b0bf245a9b288081a3e1abdcdcbe6660e6abb55dd253d8fe5faf3545554a35385cb9f0811f62b066af14aeafd98ccdada6a languageName: node linkType: hard @@ -28078,13 +28078,13 @@ __metadata: languageName: node linkType: hard -"webex@npm:3.7.0-wxcc.12": - version: 3.7.0-wxcc.12 - resolution: "webex@npm:3.7.0-wxcc.12" +"webex@npm:3.7.0-wxcc.15": + version: 3.7.0-wxcc.15 + resolution: "webex@npm:3.7.0-wxcc.15" dependencies: "@babel/polyfill": "npm:^7.12.1" "@babel/runtime-corejs2": "npm:^7.14.8" - "@webex/calling": "npm:3.6.0-wxcc.1" + "@webex/calling": "npm:3.7.0-wxcc.1" "@webex/common": "npm:3.5.0-wxcc.1" "@webex/internal-plugin-calendar": "npm:3.5.0-wxcc.1" "@webex/internal-plugin-device": "npm:3.5.0-wxcc.1" @@ -28094,7 +28094,7 @@ __metadata: "@webex/internal-plugin-voicea": "npm:3.5.0-wxcc.1" "@webex/plugin-attachment-actions": "npm:3.5.0-wxcc.1" "@webex/plugin-authorization": "npm:3.5.0-wxcc.1" - "@webex/plugin-cc": "npm:3.5.0-wxcc.18" + "@webex/plugin-cc": "npm:3.5.0-wxcc.21" "@webex/plugin-device-manager": "npm:3.5.0-wxcc.1" "@webex/plugin-logger": "npm:3.5.0-wxcc.1" "@webex/plugin-meetings": "npm:3.5.0-wxcc.1" @@ -28108,7 +28108,7 @@ __metadata: "@webex/storage-adapter-local-storage": "npm:3.5.0-wxcc.1" "@webex/webex-core": "npm:3.5.0-wxcc.1" lodash: "npm:^4.17.21" - checksum: 10c0/2c12d3df8f49bf276860dde9a7ca0f7dd0aa65ddbf9acf30918ac8638fc556adbecf918a3c2781927d0b42e9259b7fad7e1f36c47fdfd97778c7e20c158a9fcd + checksum: 10c0/0fb9690a694746d9bd4cb8e9ed48083a3cedeeb91e1befe58aa607ab78a01b7c5cfb822647ed3382fca33318771547892ab218fa534c65ce17f0793838e2e883 languageName: node linkType: hard