diff --git a/docs/react-samples/src/App.tsx b/docs/react-samples/src/App.tsx index 4e81205f..750a0df4 100644 --- a/docs/react-samples/src/App.tsx +++ b/docs/react-samples/src/App.tsx @@ -1,27 +1,43 @@ -import React, { useState } from 'react'; -import {StationLogin, UserState, store} from '@webex/cc-widgets' +import React, {useState} from 'react'; +import {StationLogin, UserState, IncomingTask, TaskList, store} from '@webex/cc-widgets'; function App() { const [isSdkReady, setIsSdkReady] = useState(false); - const [accessToken, setAccessToken] = useState(""); + const [accessToken, setAccessToken] = useState(''); const [isLoggedIn, setIsLoggedIn] = useState(false); const webexConfig = { - fedramp: false, - logger: { - level: 'log' - } - } + fedramp: false, + logger: { + level: 'log', + }, + }; const onLogin = () => { console.log('Agent login has been succesful'); setIsLoggedIn(true); - } + }; const onLogout = () => { console.log('Agent logout has been succesful'); setIsLoggedIn(false); - } + }; + + const onAccepted = () => { + console.log('onAccepted Invoked'); + }; + + const onDeclined = () => { + console.log('onDeclined invoked'); + }; + + const onTaskAccepted = () => { + console.log('onTaskAccepted invoked'); + }; + + const onTaskDeclined = () => { + console.log('onTaskDeclined invoked'); + }; return ( <> @@ -33,25 +49,30 @@ function App() { onChange={(e) => setAccessToken(e.target.value)} /> - { - isSdkReady && ( - <> - - { - isLoggedIn && - } - - ) - } + > + Init Widgets + + {isSdkReady && ( + <> + + {isLoggedIn && ( + <> + + + + + )} + + )} ); } - +// @ts-ignore +window.store = store; export default App; diff --git a/docs/web-component-samples/app.js b/docs/web-component-samples/app.js index 24ff1cac..dd338033 100644 --- a/docs/web-component-samples/app.js +++ b/docs/web-component-samples/app.js @@ -2,6 +2,8 @@ const accessTokenElem = document.getElementById('access_token_elem'); const widgetsContainer = document.getElementById('widgets-container'); const ccStationLogin = document.getElementById('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'); if (!ccStationLogin && !ccUserState) { console.error('Failed to find the required elements'); @@ -25,6 +27,10 @@ function initWidgets(){ }).then(() => { ccStationLogin.onLogin = loginSuccess; ccStationLogin.onLogout = logoutSuccess; + ccIncomingTask.onAccepted = onAccepted; + ccIncomingTask.onDeclined = onDeclined; + ccTaskList.onTaskAccepted = onTaskAccepted; + ccTaskList.onTaskDeclined = onTaskDeclined; ccStationLogin.classList.remove('disabled'); }).catch((error) => { console.error('Failed to initialize widgets:', error); @@ -35,9 +41,27 @@ function loginSuccess(){ console.log('Agent login has been succesful'); ccUserState.classList.remove('disabled'); widgetsContainer.appendChild(ccUserState); + widgetsContainer.appendChild(ccIncomingTask); + widgetsContainer.appendChild(ccTaskList); } function logoutSuccess(){ console.log('Agent logout has been succesful'); ccUserState.classList.add('disabled'); -} \ No newline at end of file +} + +function onAccepted(){ + console.log('onAccepted Invoked'); +}; + +function onDeclined(){ + console.log('onDeclined invoked'); +}; + +function onTaskAccepted(){ + console.log('onTaskAccepted invoked'); +}; + +function onTaskDeclined(){ + console.log('onTaskDeclined invoked'); +}; \ No newline at end of file diff --git a/packages/contact-center/cc-widgets/package.json b/packages/contact-center/cc-widgets/package.json index c8a35de0..919b4959 100644 --- a/packages/contact-center/cc-widgets/package.json +++ b/packages/contact-center/cc-widgets/package.json @@ -24,6 +24,7 @@ "@r2wc/react-to-web-component": "2.0.3", "@webex/cc-station-login": "workspace:*", "@webex/cc-store": "workspace:*", + "@webex/cc-task": "workspace:*", "@webex/cc-user-state": "workspace:*" }, "devDependencies": { diff --git a/packages/contact-center/cc-widgets/src/index.ts b/packages/contact-center/cc-widgets/src/index.ts index 01cd9960..69784ae4 100644 --- a/packages/contact-center/cc-widgets/src/index.ts +++ b/packages/contact-center/cc-widgets/src/index.ts @@ -1,5 +1,6 @@ import {StationLogin} from '@webex/cc-station-login'; import {UserState} from '@webex/cc-user-state'; +import {IncomingTask, TaskList} from '@webex/cc-task'; import store from '@webex/cc-store'; -export {StationLogin, UserState, store}; +export {StationLogin, UserState, IncomingTask, TaskList, store}; diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index 8d5e98ab..4daa332d 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -2,8 +2,22 @@ import r2wc from '@r2wc/react-to-web-component'; import {StationLogin} from '@webex/cc-station-login'; import {UserState} from '@webex/cc-user-state'; import store from '@webex/cc-store'; +import {TaskList, IncomingTask} from '@webex/cc-task'; const WebUserState = r2wc(UserState); +const WebIncomingTask = r2wc(IncomingTask, { + props: { + onAccepted: 'function', + onDeclined: 'function', + }, +}); + +const WebTaskList = r2wc(TaskList, { + props: { + onTaskAccepted: 'function', + onTaskDeclined: 'function', + }, +}); const WebStationLogin = r2wc(StationLogin, { props: { @@ -20,4 +34,12 @@ if (!customElements.get('widget-cc-station-login')) { customElements.define('widget-cc-station-login', WebStationLogin); } +if (!customElements.get('widget-cc-incoming-task')) { + customElements.define('widget-cc-incoming-task', WebIncomingTask); +} + +if (!customElements.get('widget-cc-task-list')) { + customElements.define('widget-cc-task-list', WebTaskList); +} + export {store}; diff --git a/packages/contact-center/station-login/src/helper.ts b/packages/contact-center/station-login/src/helper.ts index 54572129..3e38eb06 100644 --- a/packages/contact-center/station-login/src/helper.ts +++ b/packages/contact-center/station-login/src/helper.ts @@ -1,6 +1,7 @@ -import {useState} from "react"; +import {useState} from 'react'; import {StationLoginSuccess, StationLogoutSuccess} from '@webex/plugin-cc'; -import {UseStationLoginProps} from "./station-login/station-login.types"; +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 export const useStationLogin = (props: UseStationLoginProps) => { const cc = props.cc; @@ -17,10 +18,12 @@ export const useStationLogin = (props: UseStationLoginProps) => { cc.stationLogin({teamId: team, loginOption: deviceType, dialNumber: dialNumber}) .then((res: StationLoginSuccess) => { setLoginSuccess(res); - if(loginCb){ + store.setSelectedLoginOption(deviceType); + if (loginCb) { loginCb(); } - }).catch((error: Error) => { + }) + .catch((error: Error) => { console.error(error); setLoginFailure(error); }); @@ -30,14 +33,24 @@ export const useStationLogin = (props: UseStationLoginProps) => { cc.stationLogout({logoutReason: 'User requested logout'}) .then((res: StationLogoutSuccess) => { setLogoutSuccess(res); - if(logoutCb){ + if (logoutCb) { logoutCb(); } - }).catch((error: Error) => { + }) + .catch((error: Error) => { console.error(error); }); }; - return {name: 'StationLogin', setDeviceType, setDialNumber, setTeam, login, logout, loginSuccess, loginFailure, logoutSuccess}; -} - + return { + name: 'StationLogin', + setDeviceType, + setDialNumber, + setTeam, + login, + logout, + loginSuccess, + loginFailure, + logoutSuccess, + }; +}; 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 94cb813f..a729ed27 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 @@ -18,7 +18,7 @@ export interface IStationLoginProps { */ teams: Team[]; - /** + /** * Station login options available for the agent */ loginOptions: string[]; @@ -43,7 +43,7 @@ export interface IStationLoginProps { */ loginFailure?: Error; - /** + /** * Response data received on agent login success */ logoutSuccess?: StationLogoutSuccess; @@ -74,8 +74,21 @@ export interface IStationLoginProps { setTeam: (team: string) => void; } -export type StationLoginPresentationalProps = Pick; +export type StationLoginPresentationalProps = Pick< + IStationLoginProps, + | 'name' + | 'teams' + | 'loginOptions' + | 'login' + | 'logout' + | 'loginSuccess' + | 'loginFailure' + | 'logoutSuccess' + | 'setDeviceType' + | 'setDialNumber' + | 'setTeam' +>; export type UseStationLoginProps = Pick; -export type StationLoginProps = Pick; \ No newline at end of file +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 501b724b..a8e803d8 100644 --- a/packages/contact-center/station-login/tests/helper.ts +++ b/packages/contact-center/station-login/tests/helper.ts @@ -1,10 +1,22 @@ import {renderHook, act, waitFor} from '@testing-library/react'; import {useStationLogin} from '../src/helper'; +const teams = ['team123', 'team456']; + +const loginOptions = ['EXTENSION', 'AGENT_DN', 'BROWSER']; +jest.mock('@webex/cc-store', () => { + return { + cc: {}, + teams, + loginOptions, + setSelectedLoginOption: jest.fn(), + }; +}); + // Mock webex instance const ccMock = { - stationLogin: jest.fn(), - stationLogout: jest.fn(), + stationLogin: jest.fn(), + stationLogout: jest.fn(), }; // Sample login parameters @@ -18,40 +30,38 @@ const loginCb = jest.fn(); const logoutCb = jest.fn(); describe('useStationLogin Hook', () => { - afterEach(() => { jest.clearAllMocks(); }); - + it('should set loginSuccess on successful login', async () => { const successResponse = { - 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", + 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", + 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 + 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 { result } = renderHook(() => - useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb}) - ); + const {result} = renderHook(() => useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb})); result.current.setDeviceType(loginParams.loginOption); result.current.setDialNumber(loginParams.dialNumber); @@ -68,7 +78,7 @@ describe('useStationLogin Hook', () => { dialNumber: loginParams.dialNumber, }); expect(loginCb).toHaveBeenCalledWith(); - + expect(result.current).toEqual({ name: 'StationLogin', setDeviceType: expect.any(Function), @@ -78,18 +88,56 @@ describe('useStationLogin Hook', () => { logout: expect.any(Function), loginSuccess: successResponse, loginFailure: undefined, - logoutSuccess: undefined + logoutSuccess: undefined, }); + + expect(setSelectedLoginOptionSpy).toHaveBeenCalledWith(loginParams.loginOption); }); }); - it('should not call login callback if not present', async () => { + it('should not call setSelectedLoginOptionSpy if login fails', async () => { + const errorResponse = new Error('Login failed'); + ccMock.stationLogin.mockRejectedValue(errorResponse); + const setSelectedLoginOptionSpy = jest.spyOn(require('@webex/cc-store'), 'setSelectedLoginOption'); + + const {result} = renderHook(() => useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb})); + + result.current.setDeviceType(loginParams.loginOption); + result.current.setDialNumber(loginParams.dialNumber); + result.current.setTeam(loginParams.teamId); + + act(() => { + result.current.login(); + }); + waitFor(() => { + expect(ccMock.stationLogin).toHaveBeenCalledWith({ + teamId: loginParams.teamId, + loginOption: loginParams.loginOption, + dialNumber: loginParams.dialNumber, + }); + expect(loginCb).not.toHaveBeenCalledWith(); + + expect(result.current).toEqual({ + name: 'StationLogin', + setDeviceType: expect.any(Function), + setDialNumber: expect.any(Function), + setTeam: expect.any(Function), + login: expect.any(Function), + logout: expect.any(Function), + loginSuccess: undefined, + loginFailure: errorResponse, + logoutSuccess: undefined, + }); + + expect(setSelectedLoginOptionSpy).not.toHaveBeenCalled(); + }); + }); + + it('should not call login callback if not present', async () => { ccMock.stationLogin.mockResolvedValue({}); - const { result } = renderHook(() => - useStationLogin({cc: ccMock, onLogout: logoutCb}) - ); + const {result} = renderHook(() => useStationLogin({cc: ccMock, onLogout: logoutCb})); act(() => { result.current.login(); @@ -105,9 +153,7 @@ describe('useStationLogin Hook', () => { ccMock.stationLogin.mockRejectedValue(errorResponse); loginCb.mockClear(); - const { result } = renderHook(() => - useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb}) - ); + const {result} = renderHook(() => useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb})); result.current.setDeviceType(loginParams.loginOption); result.current.setDialNumber(loginParams.dialNumber); @@ -123,9 +169,9 @@ describe('useStationLogin Hook', () => { loginOption: loginParams.loginOption, dialNumber: loginParams.dialNumber, }); - + expect(loginCb).not.toHaveBeenCalledWith(); - + expect(result.current).toEqual({ name: 'StationLogin', setDeviceType: expect.any(Function), @@ -135,32 +181,30 @@ describe('useStationLogin Hook', () => { logout: expect.any(Function), loginSuccess: undefined, loginFailure: errorResponse, - logoutSuccess: undefined + logoutSuccess: undefined, }); }); }); it('should set logoutSuccess on successful logout', async () => { const successResponse = { - agentId: "6b310dff-569e-4ac7-b064-70f834ea56d8", - agentSessionId: "701ba0dc-2075-4867-a753-226ad8e2197a", + agentId: '6b310dff-569e-4ac7-b064-70f834ea56d8', + agentSessionId: '701ba0dc-2075-4867-a753-226ad8e2197a', eventTime: 1731998475193, - eventType: "AgentDesktopMessage", - loggedOutBy: "SELF", - logoutReason: "Agent Logged Out", - orgId: "6ecef209-9a34-4ed1-a07a-7ddd1dbe925a", + eventType: 'AgentDesktopMessage', + loggedOutBy: 'SELF', + logoutReason: 'Agent Logged Out', + orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', roles: ['agent'], - status: "LoggedOut", - subStatus: "Idle", - trackingId: "77170ae4-fd8d-4bf5-bfaa-5f9d8975265c", - type: "AgentLogoutSuccess" + status: 'LoggedOut', + subStatus: 'Idle', + trackingId: '77170ae4-fd8d-4bf5-bfaa-5f9d8975265c', + type: 'AgentLogoutSuccess', }; ccMock.stationLogout.mockResolvedValue(successResponse); - const {result} = renderHook(() => - useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb}) - ); + const {result} = renderHook(() => useStationLogin({cc: ccMock, onLogin: loginCb, onLogout: logoutCb})); act(() => { result.current.logout(); @@ -170,7 +214,6 @@ describe('useStationLogin Hook', () => { expect(ccMock.stationLogout).toHaveBeenCalledWith({logoutReason: 'User requested logout'}); expect(logoutCb).toHaveBeenCalledWith(); - expect(result.current).toEqual({ name: 'StationLogin', setDeviceType: expect.any(Function), @@ -180,7 +223,7 @@ describe('useStationLogin Hook', () => { logout: expect.any(Function), loginSuccess: undefined, loginFailure: undefined, - logoutSuccess: successResponse + logoutSuccess: successResponse, }); }); }); @@ -188,9 +231,7 @@ describe('useStationLogin Hook', () => { it('should not call logout callback if not present', async () => { ccMock.stationLogout.mockResolvedValue({}); - const {result} = renderHook(() => - useStationLogin({cc: ccMock, onLogin: loginCb}) - ); + const {result} = renderHook(() => useStationLogin({cc: ccMock, onLogin: loginCb})); act(() => { result.current.logout(); @@ -200,4 +241,4 @@ describe('useStationLogin Hook', () => { expect(logoutCb).not.toHaveBeenCalled(); }); }); -}) +}); diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index b20af9c4..b2b49f7d 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -22,7 +22,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "typescript": "5.6.3", - "webex": "3.7.0-wxcc.3" + "webex": "3.7.0-wxcc.4" }, "devDependencies": { "@babel/core": "7.25.2", diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index f654a122..08e5f95c 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -1,14 +1,6 @@ import {makeAutoObservable, observable} from 'mobx'; import Webex from 'webex'; -import { - IContactCenter, - Profile, - Team, - WithWebex, - IdleCode, - InitParams, - IStore -} from './store.types'; +import {IContactCenter, Profile, Team, WithWebex, IdleCode, InitParams, IStore} from './store.types'; class Store implements IStore { teams: Team[] = []; @@ -16,32 +8,39 @@ class Store implements IStore { cc: IContactCenter; idleCodes: IdleCode[] = []; agentId: string = ''; + selectedLoginOption: string = ''; constructor() { makeAutoObservable(this, {cc: observable.ref}); } + setSelectedLoginOption(option: string): void { + this.selectedLoginOption = option; + } + registerCC(webex: WithWebex['webex']): Promise { this.cc = webex.cc; - return this.cc.register().then((response: Profile) => { - this.teams = response.teams; - this.loginOptions = response.loginVoiceOptions; - this.idleCodes = response.idleCodes; - this.agentId = response.agentId; - }).catch((error) => { - console.error('Error registering contact center', error); - return Promise.reject(error); - }); + return this.cc + .register() + .then((response: Profile) => { + this.teams = response.teams; + this.loginOptions = response.loginVoiceOptions; + this.idleCodes = response.idleCodes; + this.agentId = response.agentId; + }) + .catch((error) => { + console.error('Error registering contact center', error); + return Promise.reject(error); + }); } init(options: InitParams): Promise { - if('webex' in options) { + 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 + // This has to be documented return this.registerCC(options.webex); } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { reject(new Error('Webex SDK failed to initialize')); }, 6000); @@ -49,19 +48,20 @@ class Store implements IStore { const webex = Webex.init({ config: options.webexConfig, credentials: { - access_token: options.access_token - } + access_token: options.access_token, + }, }); - + webex.once('ready', () => { clearTimeout(timer); - this.registerCC(webex).then(() => { - resolve(); - }) - .catch((error) => { - reject(error); - }) - }) + this.registerCC(webex) + .then(() => { + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); }); } } diff --git a/packages/contact-center/task/.babelrc.js b/packages/contact-center/task/.babelrc.js new file mode 100644 index 00000000..c7837973 --- /dev/null +++ b/packages/contact-center/task/.babelrc.js @@ -0,0 +1,3 @@ +const baseConfig = require('../../../.babelrc'); + +module.exports = baseConfig; diff --git a/packages/contact-center/task/package.json b/packages/contact-center/task/package.json new file mode 100644 index 00000000..e94033b2 --- /dev/null +++ b/packages/contact-center/task/package.json @@ -0,0 +1,53 @@ +{ + "name": "@webex/cc-task", + "description": "Webex Contact Center Widgets: Task", + "version": "1.0.0", + "main": "dist/index.js", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/", + "package.json" + ], + "scripts": { + "build": "yarn run -T tsc", + "build:src": "webpack && yarn run build", + "build:watch": "webpack --watch", + "test:unit": "jest --coverage" + }, + "dependencies": { + "@webex/cc-store": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "5.6.3", + "webex": "3.7.0-wxcc.4" + }, + "devDependencies": { + "@babel/core": "7.25.2", + "@babel/preset-env": "7.25.4", + "@babel/preset-react": "7.24.7", + "@babel/preset-typescript": "7.25.9", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.2", + "@testing-library/react": "16.0.1", + "@types/jest": "29.5.14", + "@types/react-test-renderer": "18", + "babel-jest": "29.7.0", + "babel-loader": "9.2.1", + "file-loader": "6.2.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "ts-loader": "9.5.1", + "webpack": "5.94.0", + "webpack-cli": "5.1.4", + "webpack-merge": "6.0.1" + }, + "jest": { + "testEnvironment": "jsdom", + "testMatch": [ + "**/tests/**/*.ts", + "**/tests/**/*.tsx" + ] + } +} diff --git a/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx b/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx new file mode 100644 index 00000000..3035d2f7 --- /dev/null +++ b/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import {IncomingTaskPresentationalProps} from '../task.types'; + +const styles: {[key: string]: React.CSSProperties} = { + box: { + backgroundColor: '#ffffff', + borderRadius: '8px', + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', + padding: '20px', + maxWidth: '800px', + margin: '0 auto', + }, + sectionBox: { + padding: '10px', + border: '1px solid #ddd', + borderRadius: '8px', + marginBottom: '20px', + }, + fieldset: { + border: '1px solid #ccc', + borderRadius: '5px', + padding: '10px', + marginBottom: '20px', + position: 'relative', + }, + legendBox: { + fontWeight: 'bold', + color: '#0052bf', + }, + container: { + border: '1px solid #ccc', + borderRadius: '8px', + padding: '16px', + width: '350px', + boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.2)', + fontFamily: 'Arial, sans-serif', + backgroundColor: '#ffffff', + display: 'flex', + flexDirection: 'column', + }, + topSection: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + iconWrapper: { + display: 'inline-block', + backgroundColor: '#d4f8e8', + borderRadius: '50%', + width: '40px', + height: '40px', + justifyContent: 'center', + alignItems: 'center', + marginRight: '10px', + }, + iconSvg: { + width: '24px', + height: '24px', + color: '#146f5c', + }, + callInfo: { + margin: 0, + fontSize: '1.2em', + color: '#333', + }, + aniText: { + fontSize: '1.1em', + fontWeight: 'bold', + margin: '4px 0', + color: '#146f5c', + }, + buttonsWrapper: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + marginLeft: '16px', + }, + answerButton: { + padding: '8px 16px', + border: 'none', + borderRadius: '6px', + fontSize: '0.9em', + cursor: 'pointer', + fontWeight: 'bold', + backgroundColor: '#28a745', + color: '#fff', + marginBottom: '8px', + }, + declineButton: { + padding: '8px 16px', + border: 'none', + borderRadius: '6px', + fontSize: '0.9em', + cursor: 'pointer', + fontWeight: 'bold', + backgroundColor: '#dc3545', + color: '#fff', + }, + queueInfo: { + fontSize: '0.9em', + color: '#666', + marginTop: '8px', + }, + timeElapsed: { + color: '#28a745', + fontWeight: 'bold', + }, + callDetails: { + marginTop: '16px', + fontSize: '0.9em', + color: '#333', + }, + detailItem: { + margin: '4px 0', + }, + detailLabel: { + color: '#555', + fontWeight: 'bold', + }, +}; + +const IncomingTaskPresentational: React.FunctionComponent = (props) => { + const {currentTask, accept, decline, isBrowser, audioRef} = props; + + if (!currentTask) { + return <>; // hidden component + } + + const callAssociationDetails = currentTask.data.interaction.callAssociatedDetails; + const {ani, dn, virtualTeamName} = callAssociationDetails; + const timeElapsed = ''; // TODO: Calculate time elapsed + + return ( +
+
+
+ Incoming Task +
+ {/* Top Section - Call Info with Phone Icon */} +
+
+ + + + + +
+

Incoming Call

+

+ {ani} +

+
+
+ + {isBrowser && ( +
+ + +
+ )} +
+ + + {/* Queue and Timer Info */} +

+ {virtualTeamName} - {timeElapsed} +

+ + {/* Call Details Section */} +
+

+ Phone Number: {ani} +

+

+ DNIS: {dn} +

+

+ Queue Name: {virtualTeamName} +

+
+
+
+
+
+ ); +}; + +export default IncomingTaskPresentational; diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx new file mode 100644 index 00000000..4fb33b11 --- /dev/null +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import {observer} from 'mobx-react'; + +import store from '@webex/cc-store'; +import {useIncomingTask} from '../helper'; +import IncomingTaskPresentational from './incoming-task.presentational'; +import {IncomingTaskProps} from '../task.types'; + +const IncomingTask: React.FunctionComponent = observer(({onAccepted, onDeclined}) => { + const {cc, selectedLoginOption} = store; + + const result = useIncomingTask({cc, onAccepted, onDeclined, selectedLoginOption}); + + const props = { + ...result, + }; + + return ; +}); + +export {IncomingTask}; diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx new file mode 100644 index 00000000..f2ce7d01 --- /dev/null +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import store from '@webex/cc-store'; +import {observer} from 'mobx-react'; + +import TaskListPresentational from './task-list.presentational'; +import {useTaskList} from '../helper'; + +const TaskList: React.FunctionComponent = observer(() => { + const {cc, selectedLoginOption} = store; + + const result = useTaskList({cc, selectedLoginOption}); + + return ; +}); + +export {TaskList}; diff --git a/packages/contact-center/task/src/TaskList/task-list.presentational.tsx b/packages/contact-center/task/src/TaskList/task-list.presentational.tsx new file mode 100644 index 00000000..ae62afdd --- /dev/null +++ b/packages/contact-center/task/src/TaskList/task-list.presentational.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import {TaskListPresentationalProps} from '../task.types'; + +const styles: {[key: string]: React.CSSProperties} = { + box: { + backgroundColor: '#ffffff', + borderRadius: '8px', + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', + padding: '20px', + maxWidth: '800px', + margin: '0 auto', + }, + container: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '20px', + }, + card: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#ffffff', + border: '1px solid #ddd', + borderRadius: '8px', + padding: '10px 15px', + boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)', + }, + leftSection: { + display: 'flex', + alignItems: 'center', + gap: '10px', + }, + icon: { + backgroundColor: '#bdf5cf', + borderRadius: '50%', + width: '40px', + height: '40px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + iconSvg: { + width: '24px', + height: '24px', + color: '#146f5c', + }, + textPrimary: { + margin: 0, + fontSize: '16px', + fontWeight: 'bold', + }, + textSecondary: { + margin: 0, + fontSize: '14px', + color: '#888', + }, + rightSectionText: { + margin: 0, + fontSize: '14px', + fontWeight: 'bold', + color: '#333', + }, + buttonsWrapper: { + display: 'flex', + gap: '10px', + }, + acceptButton: { + padding: '8px 16px', + border: 'none', + borderRadius: '6px', + fontSize: '0.9em', + cursor: 'pointer', + fontWeight: 'bold', + backgroundColor: '#28a745', + color: '#fff', + }, + rejectButton: { + padding: '8px 16px', + border: 'none', + borderRadius: '6px', + fontSize: '0.9em', + cursor: 'pointer', + fontWeight: 'bold', + backgroundColor: '#dc3545', + color: '#fff', + }, + fieldset: { + border: '1px solid #ccc', + borderRadius: '5px', + padding: '10px', + marginBottom: '20px', + position: 'relative', + }, + legendBox: { + fontWeight: 'bold', + color: '#0052bf', + }, +}; + +const TaskListPresentational: React.FunctionComponent = (props) => { + if (props.taskList.length <= 0) { + return <>; // hidden component + } + + const {taskList, acceptTask, declineTask, isBrowser} = props; + + return ( +
+
+ TaskList +
+ {taskList.map((task, index) => { + const callAssociationDetails = task.data.interaction.callAssociatedDetails; + const {ani, dn, virtualTeamName} = callAssociationDetails; + + return ( +
+ {/* Left Section with Icon and Details */} +
+
+ + + +
+
+

{ani}

+

{virtualTeamName}

+
+
+ + {/* Right Section with Call Duration and Buttons */} +
+

{dn}

+ {isBrowser && ( +
+ + +
+ )} +
+
+ ); + })} +
+
+
+ ); +}; + +export default TaskListPresentational; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts new file mode 100644 index 00000000..8a89e030 --- /dev/null +++ b/packages/contact-center/task/src/helper.ts @@ -0,0 +1,178 @@ +import {useState, useEffect, useCallback, useRef} from 'react'; +import {TASK_EVENTS, UseTaskListProps, UseTaskProps} from './task.types'; +import {ITask} from '@webex/plugin-cc'; + +// Hook for managing the task list +export const useTaskList = (props: UseTaskListProps) => { + const {cc, selectedLoginOption, onTaskAccepted, onTaskDeclined} = props; + const [taskList, setTaskList] = useState([]); + const isBrowser = selectedLoginOption === 'BROWSER'; + + 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)); + taskToRemove.off(TASK_EVENTS.TASK_UNASSIGNED, () => 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)); + task.on(TASK_EVENTS.TASK_UNASSIGNED, () => 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; + + task + .accept(taskId) + .then(() => { + onTaskAccepted && onTaskAccepted(task); + }) + .catch((error: Error) => { + console.error(error); + }); + }; + + const declineTask = (task: ITask) => { + const taskId = task?.data.interactionId; + if (!taskId) return; + + task + .decline(taskId) + .then(() => { + onTaskDeclined && onTaskDeclined(task); + }) + .catch((error: Error) => { + console.error(error); + }); + }; + + 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} = props; + const [currentTask, setCurrentTask] = useState(null); + const [isAnswered, setIsAnswered] = useState(false); + const [isEnded, setIsEnded] = useState(false); + const [isMissed, setIsMissed] = useState(false); + const audioRef = useRef(null); // Ref for the audio element + + const handleTaskAssigned = useCallback(() => { + setIsAnswered(true); + }, []); + + const handleTaskEnded = useCallback(() => { + setIsEnded(true); + setCurrentTask(null); + }, []); + + const handleTaskMissed = useCallback(() => { + setIsMissed(true); + setCurrentTask(null); + }, []); + + const handleTaskMedia = useCallback((track) => { + if (audioRef.current) { + audioRef.current.srcObject = new MediaStream([track]); + } + }, []); + + const handleIncomingTask = useCallback((task: ITask) => { + setCurrentTask(task); + }, []); + + useEffect(() => { + cc.on(TASK_EVENTS.TASK_INCOMING, handleIncomingTask); + + if (currentTask) { + currentTask.on(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); + currentTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); + currentTask.on(TASK_EVENTS.TASK_UNASSIGNED, handleTaskMissed); + currentTask.on(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); + } + + return () => { + cc.off(TASK_EVENTS.TASK_INCOMING, handleIncomingTask); + if (currentTask) { + currentTask.off(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); + currentTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); + currentTask.off(TASK_EVENTS.TASK_UNASSIGNED, handleTaskMissed); + currentTask.off(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); + } + }; + }, [cc, currentTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded, handleTaskMissed, handleTaskMedia]); + + const accept = () => { + const taskId = currentTask?.data.interactionId; + if (!taskId) return; + + currentTask + .accept(taskId) + .then(() => { + onAccepted && onAccepted(); + }) + .catch((error: Error) => { + console.error(error); + }); + }; + + const decline = () => { + const taskId = currentTask?.data.interactionId; + if (!taskId) return; + + currentTask + .decline(taskId) + .then(() => { + setCurrentTask(null); + onDeclined && onDeclined(); + }) + .catch((error: Error) => { + console.error(error); + }); + }; + + const isBrowser = selectedLoginOption === 'BROWSER'; + + return { + currentTask, + setCurrentTask, + isAnswered, + isEnded, + isMissed, + accept, + decline, + isBrowser, + audioRef, + }; +}; diff --git a/packages/contact-center/task/src/index.ts b/packages/contact-center/task/src/index.ts new file mode 100644 index 00000000..b94faf00 --- /dev/null +++ b/packages/contact-center/task/src/index.ts @@ -0,0 +1,3 @@ +import {IncomingTask} from './IncomingTask/index'; +import {TaskList} from './TaskList'; +export {IncomingTask, TaskList}; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts new file mode 100644 index 00000000..35844a34 --- /dev/null +++ b/packages/contact-center/task/src/task.types.ts @@ -0,0 +1,117 @@ +import {ITask, IContactCenter} from '@webex/plugin-cc'; + +/** + * Interface representing the TaskProps of a user. + */ +export interface TaskProps { + /** + * currentTask of the agent. + */ + currentTask: ITask; + + /** + * CC SDK Instance. + */ + cc: IContactCenter; + + /** + * Handler for task accepted + */ + onAccepted?: () => void; + + /** + * Handler for task declined + */ + onDeclined?: () => void; + + /** + * Handler for task accepted in TaskList + */ + onTaskAccepted?: (task: ITask) => void; + + /** + * Handler for task declined in TaskList + */ + onTaskDeclined?: (task: ITask) => void; + + /** + * accept incoming task action + */ + accept: () => void; + + /** + * decline incoming task action + */ + decline: () => void; + + /** + * accept task from task list + */ + acceptTask: (task: ITask) => void; + + /** + * decline task from tasklist + */ + declineTask: (task: ITask) => void; + + /** + * Flag to determine if the user is logged in with a browser option + */ + isBrowser: boolean; + + /** + * Flag to determine if the task is answered + */ + isAnswered: boolean; + + /** + * Flag to determine if the task is ended + */ + isEnded: boolean; + + /** + * Flag to determine if the task is missed + */ + isMissed: boolean; + + /** + * Selected login option + */ + selectedLoginOption: string; + + /** + * List of tasks + */ + taskList: ITask[]; + + /** + * Audio reference + */ + audioRef: React.RefObject; +} + +export type UseTaskProps = Pick; +export type UseTaskListProps = Pick; +export type IncomingTaskPresentationalProps = Pick< + TaskProps, + 'currentTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'isMissed' | 'accept' | 'decline' | 'audioRef' +>; +export type IncomingTaskProps = Pick; +export type TaskListProps = Pick; + +export type TaskListPresentationalProps = Pick; +export enum TASK_EVENTS { + TASK_INCOMING = 'task:incoming', + TASK_ASSIGNED = 'task:assigned', + TASK_MEDIA = 'task:media', + TASK_UNASSIGNED = 'task:unassigned', + 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', +} // TODO: remove this once cc sdk exports this enum diff --git a/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx b/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx new file mode 100644 index 00000000..3fab0516 --- /dev/null +++ b/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {render, screen, fireEvent, cleanup} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import IncomingTaskPresentational from '../../src/IncomingTask/incoming-task.presentational'; + +describe('IncomingTaskPresentational', () => { + afterEach(cleanup); + + it('renders incoming call for browser option', () => { + const mockTask = { + data: { + interaction: { + callAssociatedDetails: { + ani: '1234567890', + dn: '987654321', + virtualTeamName: 'Sales Team', + }, + }, + }, + }; + + const props = { + currentTask: mockTask, + accept: jest.fn(), + decline: jest.fn(), + isBrowser: true, + isAnswered: false, + isEnded: false, + isMissed: false, + }; + + render(); + + const callInfo = screen.getByTestId('incoming-task-ani'); + expect(callInfo).toHaveTextContent('1234567890'); + }); +}); diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx new file mode 100644 index 00000000..ff71c997 --- /dev/null +++ b/packages/contact-center/task/tests/IncomingTask/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import * as helper from '../../src/helper'; +import {IncomingTask} from '../../src'; +import store from '@webex/cc-store'; +import '@testing-library/jest-dom'; + +// Mock the store +jest.mock('@webex/cc-store', () => ({ + cc: {}, + selectedLoginOption: 'BROWSER', +})); + +const onAcceptedCb = jest.fn(); +const onDeclinedCb = jest.fn(); + +describe('IncomingTask Component', () => { + it('renders IncomingTaskPresentational with correct props', () => { + const useIncomingTaskSpy = jest.spyOn(helper, 'useIncomingTask'); + + // Mock the return value of the useIncomingTask hook + useIncomingTaskSpy.mockReturnValue({ + currentTask: null, + setCurrentTask: jest.fn(), + answered: false, + ended: false, + missed: false, + accept: jest.fn(), + decline: jest.fn(), + isBrowser: true, + }); + + render(); + + // Assert that the useIncomingTask hook is called with the correct arguments + expect(useIncomingTaskSpy).toHaveBeenCalledWith({ + cc: store.cc, + selectedLoginOption: store.selectedLoginOption, + onAccepted: onAcceptedCb, + onDeclined: onDeclinedCb, + }); + }); +}); diff --git a/packages/contact-center/task/tests/TaskList/index.tsx b/packages/contact-center/task/tests/TaskList/index.tsx new file mode 100644 index 00000000..4fe02fa7 --- /dev/null +++ b/packages/contact-center/task/tests/TaskList/index.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import {render, screen, cleanup} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import {TaskList} from '../../src/TaskList'; +import TaskListPresentational from '../../src/TaskList/task-list.presentational'; +import * as helper from '../../src/helper'; +import store from '@webex/cc-store'; + +// Mock `TaskListPresentational` to avoid testing its internal implementation. +jest.mock('../../src/TaskList/task-list.presentational', () => { + return jest.fn(() =>
); +}); + +// Mock `@webex/cc-store`. +jest.mock('@webex/cc-store', () => ({ + cc: {}, + selectedLoginOption: 'BROWSER', + onAccepted: jest.fn(), + onDeclined: jest.fn(), +})); + +// Mock `useTaskList`. +jest.mock('../../src/helper', () => ({ + useTaskList: jest.fn(), +})); + +describe('TaskList Component', () => { + afterEach(cleanup); + + it('renders TaskListPresentational with the correct props', () => { + const taskListMock = [ + {id: 1, data: {interaction: {callAssociatedDetails: {ani: '1234567890'}}}}, + {id: 2, data: {interaction: {callAssociatedDetails: {ani: '9876543210'}}}}, + ]; + + // Mock the return value of `useTaskList`. + const useTaskListMock = jest.spyOn(helper, 'useTaskList'); + useTaskListMock.mockReturnValue({ + taskList: taskListMock, + }); + + render(); + + // Assert that `TaskListPresentational` is rendered. + const taskListPresentational = screen.getByTestId('task-list-presentational'); + expect(taskListPresentational).toBeInTheDocument(); + + // Verify that `TaskListPresentational` is called with the correct props. + expect(TaskListPresentational).toHaveBeenCalledWith({taskList: taskListMock}, {}); + + // Verify that `useTaskList` is called with the correct arguments. + expect(helper.useTaskList).toHaveBeenCalledWith({cc: store.cc, selectedLoginOption: 'BROWSER'}); + }); +}); diff --git a/packages/contact-center/task/tests/TaskList/task-list.presentational.tsx b/packages/contact-center/task/tests/TaskList/task-list.presentational.tsx new file mode 100644 index 00000000..0defe45a --- /dev/null +++ b/packages/contact-center/task/tests/TaskList/task-list.presentational.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import {render, screen, cleanup} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TaskListPresentational from '../../src/TaskList/task-list.presentational'; +import {TaskListPresentationalProps} from '../../src/task.types'; + +describe('TaskListPresentational Component', () => { + afterEach(cleanup); + + it('renders a list of tasks when taskList is not empty', () => { + const props: TaskListPresentationalProps = { + taskList: [ + { + id: '1', + data: { + interaction: { + callAssociatedDetails: { + ani: '1234567890', + dn: '9876543210', + virtualTeamName: 'Sales Team', + }, + }, + }, + }, + { + id: '2', + data: { + interaction: { + callAssociatedDetails: { + ani: '0987654321', + dn: '8765432109', + virtualTeamName: 'Support Team', + }, + }, + }, + }, + ], + }; + + render(); + + // Ensure the details for the first task are displayed correctly + expect(screen.getByText('1234567890')).toBeInTheDocument(); + expect(screen.getByText('Sales Team')).toBeInTheDocument(); + expect(screen.getByText('9876543210')).toBeInTheDocument(); + + // Ensure the details for the second task are displayed correctly + expect(screen.getByText('0987654321')).toBeInTheDocument(); + expect(screen.getByText('Support Team')).toBeInTheDocument(); + expect(screen.getByText('8765432109')).toBeInTheDocument(); + }); +}); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts new file mode 100644 index 00000000..e4e5b8e4 --- /dev/null +++ b/packages/contact-center/task/tests/helper.ts @@ -0,0 +1,539 @@ +import {renderHook, act, waitFor} from '@testing-library/react'; +import {useIncomingTask, useTaskList} from '../src/helper'; +import {TASK_EVENTS} from '../src/task.types'; + +// Mock webex instance and task +const ccMock = { + on: jest.fn(), + off: jest.fn(), +}; + +const taskMock = { + data: { + interactionId: 'interaction1', + }, + accept: jest.fn().mockResolvedValue('Accepted'), + decline: jest.fn().mockResolvedValue('Declined'), + on: jest.fn(), + off: jest.fn(), +}; + +const onAccepted = jest.fn(); +const onDeclined = jest.fn(); +const onTaskAccepted = jest.fn(); +const onTaskDeclined = jest.fn(); + +describe('useIncomingTask Hook', () => { + let consoleErrorMock; + + beforeEach(() => { + // Mock console.error to spy on errors + consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleErrorMock.mockRestore(); + }); + + it('should register task events for the current task', async () => { + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER'}) + ); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + await waitFor(() => { + expect(taskMock.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function)); + expect(taskMock.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function)); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should not call onAccepted if it is not provided', async () => { + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted: null, onDeclined: null, selectedLoginOption: 'BROWSER'}) + ); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + act(() => { + result.current.accept(); + }); + + await waitFor(() => { + expect(onAccepted).not.toHaveBeenCalled(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should not call onDeclined if it is not provided', async () => { + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted: null, onDeclined: null, selectedLoginOption: 'BROWSER'}) + ); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + act(() => { + result.current.decline(); + }); + + await waitFor(() => { + expect(onDeclined).not.toHaveBeenCalled(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).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'}) + ); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + 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)); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should handle errors when accepting a task', async () => { + const failingTask = { + ...taskMock, + accept: jest.fn().mockRejectedValue('Error'), + decline: jest.fn(), // No-op for decline in this test + }; + + const {result} = renderHook(() => useIncomingTask({cc: ccMock, onAccepted, selectedLoginOption: 'BROWSER'})); + + act(() => { + ccMock.on.mock.calls[0][1](failingTask); + }); + + act(() => { + result.current.accept(); + }); + + await waitFor(() => { + expect(failingTask.accept).toHaveBeenCalled(); + }); + + // Ensure errors are logged in the console + expect(consoleErrorMock).toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalledWith('Error'); + }); + + it('should handle errors when declining a task', async () => { + const failingTask = { + ...taskMock, + accept: jest.fn(), // No-op for accept in this test + decline: jest.fn().mockRejectedValue('Error'), + }; + + const {result} = renderHook(() => useIncomingTask({cc: ccMock, onDeclined, selectedLoginOption: 'BROWSER'})); + + act(() => { + ccMock.on.mock.calls[0][1](failingTask); + }); + + act(() => { + result.current.decline(); + }); + + await waitFor(() => { + expect(failingTask.decline).toHaveBeenCalled(); + }); + + // Ensure errors are logged in the console + expect(consoleErrorMock).toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalledWith('Error'); + }); +}); + +describe('useTaskList Hook', () => { + let consoleErrorMock; + + beforeEach(() => { + // Mock console.error to spy on errors + consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleErrorMock.mockRestore(); + }); + + it('should call onTaskAccepted callback when provided', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskAccepted})); + + act(() => { + result.current.acceptTask(taskMock); + }); + + await waitFor(() => { + expect(onTaskAccepted).toHaveBeenCalledWith(taskMock); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should call onTaskDeclined callback when provided', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskDeclined})); + + act(() => { + result.current.declineTask(taskMock); + }); + + await waitFor(() => { + expect(onTaskDeclined).toHaveBeenCalledWith(taskMock); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should handle errors when accepting a task', async () => { + const failingTask = { + ...taskMock, + accept: jest.fn().mockRejectedValue('Error'), + decline: jest.fn(), // No-op for decline in this test + }; + + const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskAccepted, selectedLoginOption: 'BROWSER'})); + + act(() => { + ccMock.on.mock.calls[0][1](failingTask); + }); + + act(() => { + result.current.acceptTask(failingTask); + }); + + await waitFor(() => { + expect(failingTask.accept).toHaveBeenCalled(); + }); + + // Ensure errors are logged in the console + expect(consoleErrorMock).toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalledWith('Error'); + }); + + it('should handle errors when declining a task', async () => { + const failingTask = { + ...taskMock, + accept: jest.fn(), // No-op for accept in this test + decline: jest.fn().mockRejectedValue('Error'), + }; + + const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskDeclined, selectedLoginOption: 'BROWSER'})); + + act(() => { + ccMock.on.mock.calls[0][1](failingTask); + }); + + act(() => { + result.current.declineTask(failingTask); + }); + + await waitFor(() => { + expect(failingTask.decline).toHaveBeenCalled(); + }); + + // Ensure errors are logged in the console + expect(consoleErrorMock).toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalledWith('Error'); + }); + + it('should add tasks to the list on TASK_INCOMING event', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock})); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + await waitFor(() => { + expect(result.current.taskList).toContain(taskMock); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should not call onTaskAccepted if it is not provided', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null})); + + act(() => { + result.current.acceptTask(taskMock); + }); + + await waitFor(() => { + expect(onTaskAccepted).not.toHaveBeenCalled(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should not call onTaskDeclined if it is not provided', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null})); + + act(() => { + result.current.declineTask(taskMock); + }); + + await waitFor(() => { + expect(onTaskDeclined).not.toHaveBeenCalled(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should remove a task from the list when it ends', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock})); + + 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(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should update an existing task in the list', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock})); + + 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(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should deduplicate tasks by interactionId', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock})); + + 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(consoleErrorMock).not.toHaveBeenCalled(); + }); + + describe('useIncomingTask Hook - Task Events', () => { + let consoleErrorMock; + + beforeEach(() => { + // Mock console.error to spy on errors + consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleErrorMock.mockRestore(); + }); + + it('should set isAnswered to true when task is assigned', async () => { + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER'}) + ); + + // 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(consoleErrorMock).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'}) + ); + + // 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.currentTask).toBeNull(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should set isMissed to true and clear currentTask when task is missed', async () => { + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER'}) + ); + + // Simulate task being assigned + act(() => { + ccMock.on.mock.calls[0][1](taskMock); // Simulate incoming task + }); + + // Simulate task being missed + act(() => { + taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_UNASSIGNED)?.[1](); // Trigger task missed + }); + + await waitFor(() => { + expect(result.current.isMissed).toBe(true); + expect(result.current.currentTask).toBeNull(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + }); + + describe('useIncomingTask Hook - handleTaskMedia', () => { + let consoleErrorMock; + + beforeEach(() => { + // Mock console.error to spy on errors + consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(); + + // Mock the MediaStreamTrack and MediaStream classes for the test environment + global.MediaStreamTrack = jest.fn().mockImplementation(() => ({ + kind: 'audio', // Simulating an audio track + enabled: true, + id: 'track-id', + })); + + global.MediaStream = jest.fn().mockImplementation((tracks) => ({ + getTracks: () => tracks, + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleErrorMock.mockRestore(); + }); + + it('should assign track to audioRef.current.srcObject when handleTaskMedia is called', async () => { + // Mock audioRef.current to simulate an audio element with a srcObject + const mockAudioElement = { + srcObject: null, + }; + + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER'}) + ); + + // Manually assign the mocked audio element to the ref + result.current.audioRef.current = mockAudioElement; + + // Create a mock track object using the mock implementation + const mockTrack = new MediaStreamTrack(); + + // Simulate the event that triggers handleTaskMedia by invoking the on event directly + act(() => { + // Find the event handler for TASK_MEDIA and invoke it + const taskAssignedCallback = taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_MEDIA)?.[1]; + + // Trigger the TASK_MEDIA event with the mock track + if (taskAssignedCallback) { + taskAssignedCallback(mockTrack); + } + }); + + // Ensure that audioRef.current is not null + await waitFor(() => { + expect(result.current.audioRef.current).not.toBeNull(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it('should not set srcObject if audioRef.current is null', async () => { + // Mock audioRef to simulate the absence of an audio element + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER'}) + ); + result.current.audioRef.current = null; + + // Create a mock track object using the mock implementation + const mockTrack = new MediaStreamTrack(); + + // Simulate the event that triggers handleTaskMedia by invoking the on event directly + act(() => { + // Find the event handler for TASK_MEDIA and invoke it + const taskAssignedCallback = taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_MEDIA)?.[1]; + + // Trigger the TASK_MEDIA event with the mock track + if (taskAssignedCallback) { + taskAssignedCallback(mockTrack); + } + }); + + // Verify that audioRef.current is still null and no changes occurred + await waitFor(() => { + expect(result.current.audioRef.current).toBeNull(); + }); + + // Ensure no errors are logged + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/contact-center/task/tsconfig.json b/packages/contact-center/task/tsconfig.json new file mode 100644 index 00000000..3b81144d --- /dev/null +++ b/packages/contact-center/task/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "./src" + ], + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types" + }, +} \ No newline at end of file diff --git a/packages/contact-center/task/webpack.config.js b/packages/contact-center/task/webpack.config.js new file mode 100644 index 00000000..247cf278 --- /dev/null +++ b/packages/contact-center/task/webpack.config.js @@ -0,0 +1,12 @@ +const {merge} = require('webpack-merge'); +const path = require('path'); + +const baseConfig = require('../../../webpack.config'); + +module.exports = merge(baseConfig, { + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'index.js', // Set the output filename to index.js + libraryTarget: 'commonjs2', + }, +}); diff --git a/yarn.lock b/yarn.lock index 88d26d11..20ac9a36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5435,7 +5435,37 @@ __metadata: react-dom: "npm:18.3.1" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" - webex: "npm:3.7.0-wxcc.3" + webex: "npm:3.7.0-wxcc.4" + webpack: "npm:5.94.0" + webpack-cli: "npm:5.1.4" + webpack-merge: "npm:6.0.1" + languageName: unknown + linkType: soft + +"@webex/cc-task@workspace:*, @webex/cc-task@workspace:packages/contact-center/task": + version: 0.0.0-use.local + resolution: "@webex/cc-task@workspace:packages/contact-center/task" + dependencies: + "@babel/core": "npm:7.25.2" + "@babel/preset-env": "npm:7.25.4" + "@babel/preset-react": "npm:7.24.7" + "@babel/preset-typescript": "npm:7.25.9" + "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.6.2" + "@testing-library/react": "npm:16.0.1" + "@types/jest": "npm:29.5.14" + "@types/react-test-renderer": "npm:18" + "@webex/cc-store": "workspace:*" + babel-jest: "npm:29.7.0" + babel-loader: "npm:9.2.1" + file-loader: "npm:6.2.0" + jest: "npm:29.7.0" + jest-environment-jsdom: "npm:29.7.0" + react: "npm:18.3.1" + react-dom: "npm:18.3.1" + ts-loader: "npm:9.5.1" + typescript: "npm:5.6.3" + webex: "npm:3.7.0-wxcc.4" webpack: "npm:5.94.0" webpack-cli: "npm:5.1.4" webpack-merge: "npm:6.0.1" @@ -5488,6 +5518,7 @@ __metadata: "@types/react-test-renderer": "npm:18" "@webex/cc-station-login": "workspace:*" "@webex/cc-store": "workspace:*" + "@webex/cc-task": "workspace:*" "@webex/cc-user-state": "workspace:*" babel-jest: "npm:29.7.0" babel-loader: "npm:9.2.1" @@ -6382,9 +6413,9 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-cc@npm:3.5.0-wxcc.9": - version: 3.5.0-wxcc.9 - resolution: "@webex/plugin-cc@npm:3.5.0-wxcc.9" +"@webex/plugin-cc@npm:3.5.0-wxcc.10": + version: 3.5.0-wxcc.10 + resolution: "@webex/plugin-cc@npm:3.5.0-wxcc.10" dependencies: "@types/platform": "npm:1.3.4" "@webex/calling": "npm:3.6.0-wxcc.1" @@ -6392,7 +6423,7 @@ __metadata: "@webex/webex-core": "npm:3.5.0-wxcc.1" buffer: "npm:6.0.3" jest-html-reporters: "npm:3.0.11" - checksum: 10c0/238585455185112cbe13460a4477a71346c1a33a1b083704eb45b3ed737a6001f8dd221c8914160adc42482bcbdfdbd53fed9ec5a81d83a354e6e9efd573f786 + checksum: 10c0/e0ab6626f25c269c914b69128ccecdbfea6382a62ba42fd2d24992538700bda23629323e5eefca87e0afcf57fd3f5981df94c1868e3d53498c5536060dc091f1 languageName: node linkType: hard @@ -25907,9 +25938,9 @@ __metadata: languageName: node linkType: hard -"webex@npm:3.7.0-wxcc.3": - version: 3.7.0-wxcc.3 - resolution: "webex@npm:3.7.0-wxcc.3" +"webex@npm:3.7.0-wxcc.4": + version: 3.7.0-wxcc.4 + resolution: "webex@npm:3.7.0-wxcc.4" dependencies: "@babel/polyfill": "npm:^7.12.1" "@babel/runtime-corejs2": "npm:^7.14.8" @@ -25923,7 +25954,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.9" + "@webex/plugin-cc": "npm:3.5.0-wxcc.10" "@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" @@ -25937,7 +25968,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/ee83226f0d884db685b86fadf514c2cddfcbf91f8329d691be4132828e29477a6551bfebfe82a510b24b223319d0eb6badb7322d10713fec1c302a879a253e9d + checksum: 10c0/a2312fcaf3600dbe6eff1e6f7558b20dd029f67367d23c360d5f66619b7d472bade99a96f9de3cd0e0b6efb6bb0083e67253c5ecfdc4e643190b011c34ddefb4 languageName: node linkType: hard