diff --git a/packages/contact-center/cc-widgets/src/index.ts b/packages/contact-center/cc-widgets/src/index.ts index 69784ae45..eaadab1a5 100644 --- a/packages/contact-center/cc-widgets/src/index.ts +++ b/packages/contact-center/cc-widgets/src/index.ts @@ -1,6 +1,6 @@ import {StationLogin} from '@webex/cc-station-login'; import {UserState} from '@webex/cc-user-state'; -import {IncomingTask, TaskList} from '@webex/cc-task'; +import {IncomingTask, TaskList, CallControl} from '@webex/cc-task'; import store from '@webex/cc-store'; -export {StationLogin, UserState, IncomingTask, TaskList, store}; +export {StationLogin, UserState, IncomingTask, CallControl, TaskList, store}; diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index 4daa332d4..30124802e 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -2,7 +2,7 @@ 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'; +import {TaskList, IncomingTask, CallControl} from '@webex/cc-task'; const WebUserState = r2wc(UserState); const WebIncomingTask = r2wc(IncomingTask, { @@ -26,20 +26,28 @@ const WebStationLogin = r2wc(StationLogin, { }, }); -if (!customElements.get('widget-cc-user-state')) { - customElements.define('widget-cc-user-state', WebUserState); -} - -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); -} +const WebCallControl = r2wc(CallControl, { + props: { + onHoldResume: 'function', + onEnd: 'function', + onWrapup: 'function', + }, +}); -if (!customElements.get('widget-cc-task-list')) { - customElements.define('widget-cc-task-list', WebTaskList); -} +// Whenever there is a new component, add the name of the component +// and the web-component to the components object +const components = [ + {name: 'widget-cc-user-state', component: WebUserState}, + {name: 'widget-cc-station-login', component: WebStationLogin}, + {name: 'widget-cc-incoming-task', component: WebIncomingTask}, + {name: 'widget-cc-task-list', component: WebTaskList}, + {name: 'widget-cc-call-control', component: WebCallControl}, +]; + +components.forEach(({name, component}) => { + if (!customElements.get(name)) { + customElements.define(name, component); + } +}); export {store}; 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 fcfcb8c8e..519f84f7f 100644 --- a/packages/contact-center/station-login/src/station-login/index.tsx +++ b/packages/contact-center/station-login/src/station-login/index.tsx @@ -6,7 +6,7 @@ import StationLoginPresentational from './station-login.presentational'; import {useStationLogin} from '../helper'; import {StationLoginProps} from './station-login.types'; -const StationLogin: React.FunctionComponent = observer(({onLogin, onLogout}) => { +const StationLoginComponent: React.FunctionComponent = ({onLogin, onLogout}) => { const {cc, teams, loginOptions, logger, deviceType, isAgentLoggedIn} = store; const result = useStationLogin({cc, onLogin, onLogout, logger, isAgentLoggedIn}); @@ -17,6 +17,7 @@ const StationLogin: React.FunctionComponent = observer(({onLo deviceType, }; return ; -}); +}; +const StationLogin = observer(StationLoginComponent); export {StationLogin}; diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 7edde86c3..a34b4e6c4 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -8,8 +8,10 @@ import { IdleCode, InitParams, IStore, - ILogger + ILogger, + IWrapupCode, } from './store.types'; +import {ITask} from '@webex/plugin-cc'; class Store implements IStore { private static instance: Store; @@ -20,11 +22,20 @@ class Store implements IStore { idleCodes: IdleCode[] = []; agentId: string = ''; selectedLoginOption: string = ''; + wrapupCodes: IWrapupCode[] = []; + currentTask: ITask = null; isAgentLoggedIn = false; deviceType: string = ''; constructor() { - makeAutoObservable(this, {cc: observable.ref}); + makeAutoObservable(this, { + cc: observable.ref, + currentTask: observable, // Make currentTask observable + }); + } + + setCurrentTask(task: ITask): void { + this.currentTask = task; } public static getInstance(): Store { @@ -44,20 +55,24 @@ class Store implements IStore { registerCC(webex: WithWebex['webex']): Promise { this.cc = webex.cc; this.logger = this.cc.LoggerProxy; - return this.cc.register().then((response: Profile) => { - this.teams = response.teams; - this.loginOptions = response.loginVoiceOptions; - this.idleCodes = response.idleCodes; - this.agentId = response.agentId; - this.isAgentLoggedIn = response.isAgentLoggedIn; - this.deviceType = response.deviceType; - }).catch((error) => { - this.logger.error(`Error registering contact center: ${error}`, { - module: 'cc-store#store.ts', - method: 'registerCC', + return this.cc + .register() + .then((response: Profile) => { + this.teams = response.teams; + this.loginOptions = response.loginVoiceOptions; + this.idleCodes = response.idleCodes; + this.agentId = response.agentId; + this.wrapupCodes = response.wrapupCodes; + this.isAgentLoggedIn = response.isAgentLoggedIn; + this.deviceType = response.deviceType; + }) + .catch((error) => { + this.logger.error(`Error registering contact center: ${error}`, { + module: 'cc-store#store.ts', + method: 'registerCC', + }); + return Promise.reject(error); }); - return Promise.reject(error); - }); } init(options: InitParams): Promise { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 1e8b3c6ce..a5a1fc907 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -37,6 +37,11 @@ interface IStore { init(params: InitParams): Promise; } +interface IWrapupCode { + id: string; + name: string; + } + export type { IContactCenter, @@ -48,4 +53,5 @@ export type { InitParams, IStore, ILogger, + IWrapupCode } diff --git a/packages/contact-center/store/tests/store.ts b/packages/contact-center/store/tests/store.ts index 48a7386d2..5b96b1744 100644 --- a/packages/contact-center/store/tests/store.ts +++ b/packages/contact-center/store/tests/store.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable } from 'mobx'; +import {makeAutoObservable} from 'mobx'; import Webex from 'webex'; import store from '../src/store'; // Adjust the import path as necessary @@ -6,7 +6,7 @@ let mockShouldCallback = true; jest.mock('mobx', () => ({ makeAutoObservable: jest.fn(), - observable: { ref: jest.fn() } + observable: {ref: jest.fn()}, })); jest.mock('webex', () => ({ @@ -19,10 +19,10 @@ jest.mock('webex', () => ({ cc: { register: jest.fn(), LoggerProxy: { - error: jest.fn() - } - } - })) + error: jest.fn(), + }, + }, + })), })); describe('Store', () => { @@ -45,18 +45,18 @@ describe('Store', () => { expect(store.loginOptions).toEqual([]); expect(store.isAgentLoggedIn).toBe(false); expect(store.deviceType).toBe(''); - expect(makeAutoObservable).toHaveBeenCalledWith(store, { cc: expect.any(Function) }); + expect(makeAutoObservable).toHaveBeenCalledWith(store, {cc: expect.any(Function), currentTask: expect.any(Object)}); }); describe('registerCC', () => { it('should initialise store values on successful register', async () => { const mockResponse = { - teams: [{ id: 'team1', name: 'Team 1' }], + teams: [{id: 'team1', name: 'Team 1'}], loginVoiceOptions: ['option1', 'option2'], - idleCodes: [{ id: 'code1', name: 'Code 1', isSystem: false, isDefault: false }], + idleCodes: [{id: 'code1', name: 'Code 1', isSystem: false, isDefault: false}], agentId: 'agent1', isAgentLoggedIn: true, - deviceType: 'BROWSER' + deviceType: 'BROWSER', }; mockWebex.cc.register.mockResolvedValue(mockResponse); @@ -76,12 +76,11 @@ describe('Store', () => { try { await store.registerCC(mockWebex); - } - catch (error) { + } 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(store.logger.error).toHaveBeenCalledWith('Error registering contact center: Error: Register failed', { + method: 'registerCC', + module: 'cc-store#store.ts', }); } }); @@ -89,7 +88,7 @@ describe('Store', () => { describe('init', () => { it('should call registerCC if webex is in options', async () => { - const initParams = { webex: mockWebex }; + const initParams = {webex: mockWebex}; jest.spyOn(store, 'registerCC').mockResolvedValue(); Webex.init.mockClear(); @@ -101,8 +100,8 @@ describe('Store', () => { it('should initialize webex and call registerCC on ready event', async () => { const initParams = { - webexConfig: { anyConfig: true }, - access_token: 'fake_token' + webexConfig: {anyConfig: true}, + access_token: 'fake_token', }; jest.spyOn(store, 'registerCC').mockResolvedValue(); @@ -110,15 +109,15 @@ describe('Store', () => { expect(Webex.init).toHaveBeenCalledWith({ config: initParams.webexConfig, - credentials: { access_token: initParams.access_token } + credentials: {access_token: initParams.access_token}, }); expect(store.registerCC).toHaveBeenCalledWith(expect.any(Object)); }); it('should reject the promise if registerCC fails in init method', async () => { const initParams = { - webexConfig: { anyConfig: true }, - access_token: 'fake_token' + webexConfig: {anyConfig: true}, + access_token: 'fake_token', }; jest.spyOn(store, 'registerCC').mockRejectedValue(new Error('registerCC failed')); @@ -128,8 +127,8 @@ describe('Store', () => { it('should reject the promise if Webex SDK fails to initialize', async () => { const initParams = { - webexConfig: { anyConfig: true }, - access_token: 'fake_token' + webexConfig: {anyConfig: true}, + access_token: 'fake_token', }; mockShouldCallback = false; @@ -143,4 +142,4 @@ describe('Store', () => { await expect(initPromise).rejects.toThrow('Webex SDK failed to initialize'); }); }); -}); \ No newline at end of file +}); diff --git a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx new file mode 100644 index 000000000..328272161 --- /dev/null +++ b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx @@ -0,0 +1,84 @@ +import React, {useState} from 'react'; +import {WrapupCodes} from '@webex/cc-store'; + +import {CallControlPresentationalProps} from '../task.types'; +import './call-control.styles.scss'; + +function CallControlPresentational(props: CallControlPresentationalProps) { + const [isHeld, setIsHeld] = useState(false); + const [isRecording, setIsRecording] = useState(true); + const [selectedWrapupReason, setSelectedWrapupReason] = useState(null); + const [selectedWrapupId, setSelectedWrapupId] = useState(null); + + const {currentTask, audioRef, toggleHold, toggleRecording, endCall, wrapupCall, wrapupCodes, wrapupRequired} = props; + + const handletoggleHold = () => { + toggleHold(!isHeld); + setIsHeld(!isHeld); + }; + + const handletoggleRecording = () => { + toggleRecording(isRecording); + setIsRecording(!isRecording); + }; + + const handleWrapupCall = () => { + if (selectedWrapupReason && selectedWrapupId) { + wrapupCall(selectedWrapupReason, selectedWrapupId); + setSelectedWrapupReason(''); + } + }; + + const handleWrapupChange = (event: React.ChangeEvent) => { + const {text, value} = event.target.options[event.target.selectedIndex]; + setSelectedWrapupReason(text); + setSelectedWrapupId(value); + }; + + return ( + <> + + {currentTask && ( +
+
+
+ Call Control +
+
+ + + +
+
+ + +
+
+
+
+
+ )} + + ); +} + +export default CallControlPresentational; diff --git a/packages/contact-center/task/src/CallControl/call-control.styles.scss b/packages/contact-center/task/src/CallControl/call-control.styles.scss new file mode 100644 index 000000000..09e25c091 --- /dev/null +++ b/packages/contact-center/task/src/CallControl/call-control.styles.scss @@ -0,0 +1,60 @@ +.box { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.section-box { + padding: 10px; + border: 1px solid #ddd; + border-radius: 8px; +} + +.fieldset { + border: 1px solid #ccc; + border-radius: 5px; + padding: 10px; + margin-bottom: 20px; +} + +.legend-box { + font-weight: bold; + color: #0052bf; +} + +.btn { + padding: 10px 20px; + background-color: #0052bf; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s; + margin-right: 8px; +} + +.btn:disabled { + background-color: grey; + cursor: not-allowed; +} + +.select { + width: 100%; + padding: 8px; + margin-top: 8px; + margin-bottom: 12px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.input { + width: 97%; + padding: 8px; + margin-top: 8px; + margin-bottom: 12px; + border: 1px solid #ccc; + border-radius: 4px; +} diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx new file mode 100644 index 000000000..aeda9372a --- /dev/null +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {observer} from 'mobx-react-lite'; + +import store from '@webex/cc-store'; +import {useCallControl} from '../helper'; +import {CallControlProps} from '../task.types'; +import CallControlPresentational from './call-control.presentational'; + +const CallControlComponent: React.FunctionComponent = ({onHoldResume, onEnd, onWrapUp}) => { + const {logger, currentTask, wrapupCodes} = store; + + const result = useCallControl({currentTask, onHoldResume, onEnd, onWrapUp, logger}); + + return ; +}; + +const CallControl = observer(CallControlComponent); +export {CallControl}; 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 3035d2f7a..228dc4388 100644 --- a/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx +++ b/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx @@ -120,13 +120,13 @@ const styles: {[key: string]: React.CSSProperties} = { }; const IncomingTaskPresentational: React.FunctionComponent = (props) => { - const {currentTask, accept, decline, isBrowser, audioRef} = props; + const {incomingTask, accept, decline, isBrowser, isAnswered} = props; - if (!currentTask) { + if (!incomingTask || isAnswered) { return <>; // hidden component } - const callAssociationDetails = currentTask.data.interaction.callAssociatedDetails; + const callAssociationDetails = incomingTask.data.interaction.callAssociatedDetails; const {ani, dn, virtualTeamName} = callAssociationDetails; const timeElapsed = ''; // TODO: Calculate time elapsed @@ -172,7 +172,6 @@ const IncomingTaskPresentational: React.FunctionComponent )} - {/* Queue and Timer Info */}

diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index 06f03a932..ff6d19eab 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -6,7 +6,7 @@ import {useIncomingTask} from '../helper'; import IncomingTaskPresentational from './incoming-task.presentational'; import {IncomingTaskProps} from '../task.types'; -const IncomingTask: React.FunctionComponent = observer(({onAccepted, onDeclined}) => { +const IncomingTaskComponent: React.FunctionComponent = ({onAccepted, onDeclined}) => { const {cc, selectedLoginOption, logger} = store; const result = useIncomingTask({cc, onAccepted, onDeclined, selectedLoginOption, logger}); @@ -16,6 +16,7 @@ const IncomingTask: React.FunctionComponent = observer(({onAc }; return ; -}); +}; +const IncomingTask = observer(IncomingTaskComponent); export {IncomingTask}; diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx index c1eeda3fc..e87487b9a 100644 --- a/packages/contact-center/task/src/TaskList/index.tsx +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -5,12 +5,17 @@ import {observer} from 'mobx-react-lite'; import TaskListPresentational from './task-list.presentational'; import {useTaskList} from '../helper'; -const TaskList: React.FunctionComponent = observer(() => { - const {cc, selectedLoginOption, logger} = store; +const TaskListComponent: React.FunctionComponent = () => { + const {cc, currentTask, selectedLoginOption, logger} = store; const result = useTaskList({cc, selectedLoginOption, logger}); + const props = { + ...result, + currentTask, + }; - return ; -}); + return ; +}; +const TaskList = observer(TaskListComponent); 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 index ae62afdd5..b8fa2dbb6 100644 --- a/packages/contact-center/task/src/TaskList/task-list.presentational.tsx +++ b/packages/contact-center/task/src/TaskList/task-list.presentational.tsx @@ -103,7 +103,7 @@ const TaskListPresentational: React.FunctionComponent; // hidden component } - const {taskList, acceptTask, declineTask, isBrowser} = props; + const {currentTask, taskList, acceptTask, declineTask, isBrowser} = props; return (

@@ -139,19 +139,21 @@ const TaskListPresentational: React.FunctionComponent {/* Right Section with Call Duration and Buttons */} -
-

{dn}

- {isBrowser && ( -
- - -
- )} -
+ {!currentTask && ( +
+

{dn}

+ {isBrowser && ( +
+ + +
+ )} +
+ )}
); })} diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index c2ad49dd2..a30510692 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -1,6 +1,7 @@ import {useState, useEffect, useCallback, useRef} from 'react'; -import {TASK_EVENTS, UseTaskListProps, UseTaskProps} from './task.types'; 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) => { @@ -8,6 +9,13 @@ export const useTaskList = (props: UseTaskListProps) => { const [taskList, setTaskList] = useState([]); const isBrowser = selectedLoginOption === 'BROWSER'; + const logError = (message: string, method: string) => { + logger.error(message, { + module: 'widget-cc-task#helper.ts', + method: `useTaskList#${method}`, + }); + }; + const handleTaskRemoved = useCallback((taskId: string) => { setTaskList((prev) => { const taskToRemove = prev.find((task) => task.data.interactionId === taskId); @@ -15,7 +23,6 @@ export const useTaskList = (props: UseTaskListProps) => { 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); @@ -31,7 +38,6 @@ export const useTaskList = (props: UseTaskListProps) => { // 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]; }); @@ -46,13 +52,11 @@ export const useTaskList = (props: UseTaskListProps) => { task .accept(taskId) .then(() => { + store.setCurrentTask(task); onTaskAccepted && onTaskAccepted(task); }) .catch((error: Error) => { - logger.error(`Error accepting task: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useTaskList#acceptTask', - }); + logError(`Error accepting task: ${error}`, 'acceptTask'); }); }; @@ -64,12 +68,10 @@ export const useTaskList = (props: UseTaskListProps) => { .decline(taskId) .then(() => { onTaskDeclined && onTaskDeclined(task); + store.setCurrentTask(null); }) .catch((error: Error) => { - logger.error(`Error declining task: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useTaskList#declineTask', - }); + logError(`Error declining task: ${error}`, 'declineTask'); }); }; @@ -88,103 +90,199 @@ export const useTaskList = (props: UseTaskListProps) => { // Hook for managing the current task export const useIncomingTask = (props: UseTaskProps) => { const {cc, onAccepted, onDeclined, selectedLoginOption, logger} = props; - const [currentTask, setCurrentTask] = useState(null); + const [incomingTask, setIncomingTask] = 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 isBrowser = selectedLoginOption === 'BROWSER'; + + const logError = (message: string, method: string) => { + logger.error(message, { + module: 'widget-cc-task#helper.ts', + method: `useIncomingTask#${method}`, + }); + }; 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); - setCurrentTask(null); - }, []); - - const handleTaskMissed = useCallback(() => { - setIsMissed(true); - setCurrentTask(null); - }, []); - - const handleTaskMedia = useCallback((track) => { - if (audioRef.current) { - audioRef.current.srcObject = new MediaStream([track]); - } + setIncomingTask(null); }, []); const handleIncomingTask = useCallback((task: ITask) => { - setCurrentTask(task); + setIncomingTask(task); + setIsAnswered(false); + setIsEnded(false); }, []); 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); + 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 (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); + if (incomingTask) { + incomingTask.off(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); + incomingTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); } }; - }, [cc, currentTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded, handleTaskMissed, handleTaskMedia]); + }, [cc, incomingTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded]); const accept = () => { - const taskId = currentTask?.data.interactionId; + const taskId = incomingTask?.data.interactionId; if (!taskId) return; - currentTask + 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) => { - logger.error(`Error accepting incoming task: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useIncomingTask#accept', - }); + logError(`Error accepting incoming task: ${error}`, 'accept'); }); }; const decline = () => { - const taskId = currentTask?.data.interactionId; + const taskId = incomingTask?.data.interactionId; if (!taskId) return; - currentTask + incomingTask .decline(taskId) .then(() => { - setCurrentTask(null); + setIncomingTask(null); + store.setCurrentTask(null); onDeclined && onDeclined(); }) .catch((error: Error) => { - logger.error(`Error declining incoming task: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useIncomingTask#decline', - }); + logError(`Error declining incoming task: ${error}`, 'decline'); }); }; - const isBrowser = selectedLoginOption === 'BROWSER'; - return { - currentTask, - setCurrentTask, + incomingTask, isAnswered, isEnded, - isMissed, accept, decline, isBrowser, + }; +}; + +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) => { + logger.error(message, { + module: 'widget-cc-task#helper.ts', + method: `useCallControl#${method}`, + }); + }; + + const handleTaskEnded = useCallback(({wrapupRequired}: {wrapupRequired: boolean}) => { + setWrapupRequired(wrapupRequired); + }, []); + + const handleTaskMedia = useCallback( + (track) => { + if (audioRef.current) { + audioRef.current.srcObject = new MediaStream([track]); + } + }, + [audioRef, currentTask] + ); + + useEffect(() => { + if (!currentTask) return; + 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]); + + const toggleHold = (hold: boolean) => { + if (hold) { + currentTask + .hold() + .then(() => onHoldResume && onHoldResume()) + .catch((error: Error) => { + logError(`Error holding call: ${error}`, 'toggleHold'); + }); + + return; + } + + currentTask + .resume() + .then(() => onHoldResume && onHoldResume()) + .catch((error: Error) => { + logError(`Error resuming call: ${error}`, 'toggleHold'); + }); + }; + + 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'); + }); + } else { + currentTask.resumeRecording().catch((error: Error) => { + logError(`Error resuming recording: ${error}`, 'toggleRecording'); + }); + } + }; + + const endCall = () => { + currentTask + .end() + .then(() => { + if (onEnd) onEnd(); + }) + .catch((error: Error) => { + logError(`Error ending call: ${error}`, 'endCall'); + }); + }; + + const wrapupCall = (wrapUpReason: string, auxCodeId: string) => { + currentTask + .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) + .then(() => { + setWrapupRequired(false); + store.setCurrentTask(null); + if (onWrapUp) onWrapUp(); + }) + .catch((error: Error) => { + logError(`Error wrapping up call: ${error}`, 'wrapupCall'); + }); + }; + + return { + currentTask, audioRef, + endCall, + toggleHold, + toggleRecording, + wrapupCall, + wrapupRequired, }; }; diff --git a/packages/contact-center/task/src/index.ts b/packages/contact-center/task/src/index.ts index b94faf002..60531ad6a 100644 --- a/packages/contact-center/task/src/index.ts +++ b/packages/contact-center/task/src/index.ts @@ -1,3 +1,4 @@ import {IncomingTask} from './IncomingTask/index'; import {TaskList} from './TaskList'; -export {IncomingTask, TaskList}; +import {CallControl} from './CallControl'; +export {IncomingTask, TaskList, CallControl}; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index b935ac4c4..55fb1c2ad 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -1,5 +1,5 @@ import {ITask, IContactCenter} from '@webex/plugin-cc'; -import {ILogger} from '@webex/cc-store'; +import {ILogger, WrapupCodes} from '@webex/cc-store'; /** * Interface representing the TaskProps of a user. @@ -10,6 +10,11 @@ export interface TaskProps { */ currentTask: ITask; + /** + * Incoming task on the incoming task widget + */ + incomingTask: ITask; + /** * CC SDK Instance. */ @@ -70,11 +75,6 @@ export interface TaskProps { */ isEnded: boolean; - /** - * Flag to determine if the task is missed - */ - isMissed: boolean; - /** * Selected login option */ @@ -85,11 +85,6 @@ export interface TaskProps { */ taskList: ITask[]; - /** - * Audio reference - */ - audioRef: React.RefObject; - /** * The logger instance from SDK */ @@ -97,20 +92,25 @@ export interface TaskProps { } export type UseTaskProps = Pick; -export type UseTaskListProps = Pick; +export type UseTaskListProps = Pick< + TaskProps, + 'cc' | 'selectedLoginOption' | 'onTaskAccepted' | 'onTaskDeclined' | 'logger' +>; export type IncomingTaskPresentationalProps = Pick< TaskProps, - 'currentTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'isMissed' | 'accept' | 'decline' | 'audioRef' + 'incomingTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' >; export type IncomingTaskProps = Pick; export type TaskListProps = Pick; -export type TaskListPresentationalProps = Pick; +export type TaskListPresentationalProps = Pick< + TaskProps, + 'currentTask' | 'taskList' | 'isBrowser' | 'acceptTask' | 'declineTask' +>; 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', @@ -121,3 +121,88 @@ export enum TASK_EVENTS { TASK_END = 'task:end', TASK_WRAPUP = 'task:wrapup', } // TODO: remove this once cc sdk exports this enum + +/** + * Interface representing the properties for control actions on a task. + */ +export interface ControlProps { + /** + * Audio reference + */ + audioRef: React.RefObject; + /** + * The current task being handled. + */ + currentTask: ITask; + + /** + * Function to handle hold/resume actions. + */ + onHoldResume: () => void; + + /** + * Function to handle ending the task. + */ + onEnd: () => void; + + /** + * Function to handle wrapping up the task. + */ + onWrapUp: () => void; + + /** + * Logger instance for logging purposes. + */ + logger: ILogger; + + /** + * Array of wrap-up codes. + * TODO: Expose this type from SDK. + */ + wrapupCodes: WrapupCodes[]; + + /** + * Indicates if wrap-up is required. + */ + wrapupRequired: boolean; + + /** + * Function to handle hold/resume actions with a boolean parameter. + * @param hold - Boolean indicating whether to hold (true) or resume (false). + */ + toggleHold: (hold: boolean) => void; + + /** + * Function to handle pause/resume recording actions with a boolean parameter. + * @param pause - Boolean indicating whether to pause (true) or resume (false) recording. + */ + toggleRecording: (pause: boolean) => void; + + /** + * Function to handle ending the call. + */ + endCall: () => void; + + /** + * Function to handle wrapping up the call with a reason and ID. + * @param wrapupReason - The reason for wrapping up the call. + * @param wrapupId - The ID associated with the wrap-up reason. + */ + wrapupCall: (wrapupReason: string, wrapupId: string) => void; +} + +export type CallControlProps = Pick; + +export type CallControlPresentationalProps = Pick< + ControlProps, + | 'currentTask' + | 'audioRef' + | 'wrapupCodes' + | 'wrapupRequired' + | 'toggleHold' + | 'toggleRecording' + | 'endCall' + | 'wrapupCall' +>; + +export type useCallControlProps = Pick; diff --git a/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx b/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx new file mode 100644 index 000000000..6e0c4831a --- /dev/null +++ b/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CallControlPresentational from '../../src/CallControl/call-control.presentational'; + +describe('CallControlPresentational', () => { + const mockToggleHold = jest.fn(); + const mockToggleRecording = jest.fn(); + const mockEndCall = jest.fn(); + const mockWrapupCall = jest.fn(); + const mockWrapupCodes = [ + {id: '1', name: 'Reason 1'}, + {id: '2', name: 'Reason 2'}, + ]; + + const defaultProps = { + currentTask: {}, + toggleHold: mockToggleHold, + toggleRecording: mockToggleRecording, + endCall: mockEndCall, + wrapupCall: mockWrapupCall, + wrapupCodes: mockWrapupCodes, + wrapupRequired: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the component with buttons and dropdown', () => { + render(); + + expect(screen.getByText('Hold')).toBeInTheDocument(); + expect(screen.getByText('Pause Recording')).toBeInTheDocument(); + expect(screen.getByText('End')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('Wrap Up')).toBeInTheDocument(); + }); + + it('calls toggleHold with the correct argument when Hold/Pause button is clicked', () => { + render(); + + const holdButton = screen.getByText('Hold'); + fireEvent.click(holdButton); + + expect(mockToggleHold).toHaveBeenCalledWith(true); + + fireEvent.click(holdButton); + expect(mockToggleHold).toHaveBeenCalledWith(false); + }); + + it('calls toggleRecording with the correct argument when Pause/Pause Recording button is clicked', () => { + render(); + + const pauseButton = screen.getByText('Pause Recording'); + fireEvent.click(pauseButton); + + expect(mockToggleRecording).toHaveBeenCalledWith(true); + + fireEvent.click(pauseButton); + expect(mockToggleRecording).toHaveBeenCalledWith(false); + }); + + it('calls endCall when End button is clicked', () => { + render(); + + const endButton = screen.getByText('End'); + fireEvent.click(endButton); + + expect(mockEndCall).toHaveBeenCalled(); + }); + + it('calls wrapupCall with the selected reason and ID when Wrap Up button is clicked', () => { + const propsWithWrapupRequired = {...defaultProps, wrapupRequired: true}; + render(); + + const select = screen.getByRole('combobox'); + fireEvent.change(select, {target: {value: '1'}}); + + const wrapupButton = screen.getByText('Wrap Up'); + fireEvent.click(wrapupButton); + + expect(mockWrapupCall).toHaveBeenCalledWith('Reason 1', '1'); + }); + + it('disables buttons and dropdown when wrapupRequired is false', () => { + render(); + + const holdButton = screen.getByText('Hold'); + const pauseButton = screen.getByText('Pause Recording'); + const endButton = screen.getByText('End'); + const select = screen.getByRole('combobox'); + + expect(holdButton).not.toBeDisabled(); + expect(pauseButton).not.toBeDisabled(); + expect(endButton).not.toBeDisabled(); + expect(select).toBeDisabled(); + }); + + it('enables Wrap Up button when a reason is selected and wrapupRequired is true', () => { + const propsWithWrapupRequired = {...defaultProps, wrapupRequired: true}; + render(); + + const select = screen.getByRole('combobox'); + fireEvent.change(select, {target: {value: '1'}}); + + const wrapupButton = screen.getByText('Wrap Up'); + expect(wrapupButton).not.toBeDisabled(); + }); +}); diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx new file mode 100644 index 000000000..7f467d65b --- /dev/null +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import * as helper from '../../src/helper'; +import {CallControl} from '../../src'; +import store from '@webex/cc-store'; +import '@testing-library/jest-dom'; + +// Mock the store +jest.mock('@webex/cc-store', () => ({ + cc: {}, + selectedLoginOption: 'BROWSER', + wrapupCodes: [], + logger: {}, + currentTask: { + on: jest.fn(), + off: jest.fn(), + hold: jest.fn(() => Promise.resolve()), + resume: jest.fn(() => Promise.resolve()), + pauseRecording: jest.fn(() => Promise.resolve()), + resumeRecording: jest.fn(() => Promise.resolve()), + end: jest.fn(() => Promise.resolve()), + wrapup: jest.fn(() => Promise.resolve()), + }, +})); +const onHoldResumeCb = jest.fn(); +const onEndCb = jest.fn(); +const onWrapUpCb = jest.fn(); + +describe('CallControl Component', () => { + it('renders CallControlPresentational with correct props', () => { + const useCallControlSpy = jest.spyOn(helper, 'useCallControl'); + + const mockCurentTask = store.currentTask; + + render(); + + // Assert that the useIncomingTask hook is called with the correct arguments + expect(useCallControlSpy).toHaveBeenCalledWith({ + currentTask: mockCurentTask, + onHoldResume: onHoldResumeCb, + onEnd: onEndCb, + onWrapUp: onWrapUpCb, + logger: {}, + }); + }); +}); diff --git a/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx b/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx index 3fab05166..2704400d6 100644 --- a/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx +++ b/packages/contact-center/task/tests/IncomingTask/incoming-task.presentational.tsx @@ -20,7 +20,7 @@ describe('IncomingTaskPresentational', () => { }; const props = { - currentTask: mockTask, + incomingTask: mockTask, accept: jest.fn(), decline: jest.fn(), isBrowser: true, diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 311c8eced..ab8948979 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,6 +1,7 @@ import {renderHook, act, waitFor} from '@testing-library/react'; -import {useIncomingTask, useTaskList} from '../src/helper'; +import {useIncomingTask, useTaskList, useCallControl} from '../src/helper'; import {TASK_EVENTS} from '../src/task.types'; +import React from 'react'; // Mock webex instance and task const ccMock = { @@ -24,11 +25,10 @@ const onTaskAccepted = jest.fn(); const onTaskDeclined = jest.fn(); const logger = { - error: jest.fn() + error: jest.fn(), }; describe('useIncomingTask Hook', () => { - afterEach(() => { jest.clearAllMocks(); logger.error.mockRestore(); @@ -121,7 +121,9 @@ describe('useIncomingTask Hook', () => { decline: jest.fn(), // No-op for decline in this test }; - const {result} = renderHook(() => useIncomingTask({cc: ccMock, onAccepted, selectedLoginOption: 'BROWSER', logger})); + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onAccepted, selectedLoginOption: 'BROWSER', logger}) + ); act(() => { ccMock.on.mock.calls[0][1](failingTask); @@ -150,7 +152,9 @@ describe('useIncomingTask Hook', () => { decline: jest.fn().mockRejectedValue('Error'), }; - const {result} = renderHook(() => useIncomingTask({cc: ccMock, onDeclined, selectedLoginOption: 'BROWSER', logger})); + const {result} = renderHook(() => + useIncomingTask({cc: ccMock, onDeclined, selectedLoginOption: 'BROWSER', logger}) + ); act(() => { ccMock.on.mock.calls[0][1](failingTask); @@ -174,14 +178,13 @@ describe('useIncomingTask Hook', () => { }); describe('useTaskList Hook', () => { - 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, selectedLoginOption: '', onTaskAccepted, logger})); act(() => { result.current.acceptTask(taskMock); @@ -196,7 +199,7 @@ describe('useTaskList Hook', () => { }); it('should call onTaskDeclined callback when provided', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, selectedLoginOption:'', onTaskDeclined, logger})); + const {result} = renderHook(() => useTaskList({cc: ccMock, selectedLoginOption: '', onTaskDeclined, logger})); act(() => { result.current.declineTask(taskMock); @@ -217,7 +220,9 @@ describe('useTaskList Hook', () => { decline: jest.fn(), // No-op for decline in this test }; - const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskAccepted, selectedLoginOption: 'BROWSER', logger})); + const {result} = renderHook(() => + useTaskList({cc: ccMock, onTaskAccepted, selectedLoginOption: 'BROWSER', logger}) + ); act(() => { ccMock.on.mock.calls[0][1](failingTask); @@ -246,7 +251,9 @@ describe('useTaskList Hook', () => { decline: jest.fn().mockRejectedValue('Error'), }; - const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskDeclined, selectedLoginOption: 'BROWSER', logger})); + const {result} = renderHook(() => + useTaskList({cc: ccMock, onTaskDeclined, selectedLoginOption: 'BROWSER', logger}) + ); act(() => { ccMock.on.mock.calls[0][1](failingTask); @@ -269,7 +276,7 @@ describe('useTaskList Hook', () => { }); it('should add tasks to the list on TASK_INCOMING event', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption:''})); + const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); act(() => { ccMock.on.mock.calls[0][1](taskMock); @@ -284,7 +291,9 @@ describe('useTaskList Hook', () => { }); it('should not call onTaskAccepted if it is not provided', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null, logger, selectedLoginOption:''})); + const {result} = renderHook(() => + useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null, logger, selectedLoginOption: ''}) + ); act(() => { result.current.acceptTask(taskMock); @@ -299,7 +308,9 @@ 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:''})); + const {result} = renderHook(() => + useTaskList({cc: ccMock, onTaskAccepted: null, onTaskDeclined: null, logger, selectedLoginOption: ''}) + ); act(() => { result.current.declineTask(taskMock); @@ -314,7 +325,7 @@ describe('useTaskList Hook', () => { }); it('should remove a task from the list when it ends', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption:''})); + const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); act(() => { ccMock.on.mock.calls[0][1](taskMock); @@ -333,7 +344,7 @@ describe('useTaskList Hook', () => { }); it('should update an existing task in the list', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption:''})); + const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); act(() => { ccMock.on.mock.calls[0][1](taskMock); @@ -351,7 +362,7 @@ describe('useTaskList Hook', () => { }); it('should deduplicate tasks by interactionId', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption:''})); + const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); act(() => { ccMock.on.mock.calls[0][1](taskMock); @@ -367,7 +378,6 @@ describe('useTaskList Hook', () => { }); describe('useIncomingTask Hook - Task Events', () => { - afterEach(() => { jest.clearAllMocks(); logger.error.mockRestore(); @@ -375,7 +385,14 @@ describe('useTaskList Hook', () => { it('should set isAnswered to true when task is assigned', async () => { const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger, selectedLoginOption:''}) + useIncomingTask({ + cc: ccMock, + onAccepted, + onDeclined, + selectedLoginOption: 'BROWSER', + logger, + selectedLoginOption: '', + }) ); // Simulate task being assigned @@ -397,7 +414,14 @@ describe('useTaskList Hook', () => { 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:''}) + useIncomingTask({ + cc: ccMock, + onAccepted, + onDeclined, + selectedLoginOption: 'BROWSER', + logger, + selectedLoginOption: '', + }) ); // Simulate task being assigned @@ -412,122 +436,465 @@ describe('useTaskList Hook', () => { await waitFor(() => { expect(result.current.isEnded).toBe(true); - expect(result.current.currentTask).toBeNull(); + expect(result.current.incomingTask).toBeNull(); }); // Ensure no errors are logged expect(logger.error).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', logger, selectedLoginOption:''}) - ); +describe('useCallControl', () => { + const mockCurrentTask = { + on: jest.fn(), + off: jest.fn(), + hold: jest.fn(() => Promise.resolve()), + resume: jest.fn(() => Promise.resolve()), + pauseRecording: jest.fn(() => Promise.resolve()), + resumeRecording: jest.fn(() => Promise.resolve()), + end: jest.fn(() => Promise.resolve()), + wrapup: jest.fn(() => Promise.resolve()), + }; + + const mockLogger = { + error: jest.fn(), + }; + + const mockOnHoldResume = jest.fn(); + const mockOnEnd = jest.fn(); + const mockOnWrapUp = jest.fn(); + + beforeEach(() => { + // 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, + })); + jest.clearAllMocks(); + }); - // Simulate task being assigned - act(() => { - ccMock.on.mock.calls[0][1](taskMock); // Simulate incoming task - }); + afterEach(() => { + jest.clearAllMocks(); + logger.error.mockRestore(); + }); - // Simulate task being missed - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_UNASSIGNED)?.[1](); // Trigger task missed - }); + it('should set up and clean up event listeners on currentTask', () => { + renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - await waitFor(() => { - expect(result.current.isMissed).toBe(true); - expect(result.current.currentTask).toBeNull(); - }); + expect(mockCurrentTask.on).toHaveBeenCalledWith('task:end', expect.any(Function)); + + // Cleanup on unmount + const {unmount} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); + unmount(); + + expect(mockCurrentTask.off).toHaveBeenCalledWith('task:end', expect.any(Function)); + }); + + it('should call holdResume with hold=true and handle success', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleHold(true); }); + + expect(mockCurrentTask.hold).toHaveBeenCalled(); + expect(mockOnHoldResume).toHaveBeenCalled(); }); - describe('useIncomingTask Hook - handleTaskMedia', () => { + it('should call holdResume with hold=false and handle success', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - beforeEach(() => { - // 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', - })); + await act(async () => { + await result.current.toggleHold(false); + }); - global.MediaStream = jest.fn().mockImplementation((tracks) => ({ - getTracks: () => tracks, - })); + expect(mockCurrentTask.resume).toHaveBeenCalled(); + expect(mockOnHoldResume).toHaveBeenCalled(); + }); + + it('should log an error if hold fails', async () => { + mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleHold(true); }); - afterEach(() => { - jest.clearAllMocks(); - logger.error.mockRestore(); + expect(mockLogger.error).toHaveBeenCalledWith('Error holding call: Error: Hold error', expect.any(Object)); + }); + + it('should log an error if hold fails', async () => { + mockCurrentTask.resume.mockRejectedValueOnce(new Error('Resume error')); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleHold(false); }); - 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, - }; + expect(mockLogger.error).toHaveBeenCalledWith('Error resuming call: Error: Resume error', expect.any(Object)); + }); - const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger, selectedLoginOption:''}) - ); + it('should call endCall and handle success', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - // Manually assign the mocked audio element to the ref - result.current.audioRef.current = mockAudioElement; + await act(async () => { + await result.current.endCall(); + }); - // Create a mock track object using the mock implementation - const mockTrack = new MediaStreamTrack(); + expect(mockCurrentTask.end).toHaveBeenCalled(); + expect(mockOnEnd).toHaveBeenCalled(); + }); - // 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]; + it('should update wrapupRequired on TASK_END event', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - // Trigger the TASK_MEDIA event with the mock track - if (taskAssignedCallback) { - taskAssignedCallback(mockTrack); - } - }); + 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); + }); - // Ensure that audioRef.current is not null - await waitFor(() => { - expect(result.current.audioRef.current).not.toBeNull(); - }); + it('should call endCall and handle failure', async () => { + mockCurrentTask.end.mockRejectedValueOnce(new Error('End error')); + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); + await act(async () => { + await result.current.endCall(); }); - 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', logger, }) - ); - result.current.audioRef.current = null; + expect(mockCurrentTask.end).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith('Error ending call: Error: End error', expect.any(Object)); + }); - // Create a mock track object using the mock implementation - const mockTrack = new MediaStreamTrack(); + it('should call wrapupCall and handle success', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); - // 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]; + await act(async () => { + await result.current.wrapupCall('Wrap reason', 123); + }); - // Trigger the TASK_MEDIA event with the mock track - if (taskAssignedCallback) { - taskAssignedCallback(mockTrack); - } - }); + expect(mockCurrentTask.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: 123}); + expect(mockOnWrapUp).toHaveBeenCalled(); + }); - // Verify that audioRef.current is still null and no changes occurred - await waitFor(() => { - expect(result.current.audioRef.current).toBeNull(); - }); + it('should log an error if wrapup fails', async () => { + mockCurrentTask.wrapup.mockRejectedValueOnce(new Error('Wrapup error')); - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.wrapupCall('Wrap reason', 123); + }); + + expect(mockLogger.error).toHaveBeenCalledWith('Error wrapping up call: Error: Wrapup error', expect.any(Object)); + }); + + it('should pause the recording when pauseResume is called with true', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleRecording(true); + }); + + expect(mockCurrentTask.pauseRecording).toHaveBeenCalledWith(); + }); + + it('should fail and log error if pause failed', async () => { + mockCurrentTask.pauseRecording.mockRejectedValueOnce(new Error('Pause error')); + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleRecording(true); + }); + + expect(mockLogger.error).toHaveBeenCalledWith('Error pausing recording: Error: Pause error', expect.any(Object)); + }); + + it('should resume the recording when pauseResume is called with false', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleRecording(false); }); + + expect(mockCurrentTask.resumeRecording).toHaveBeenCalledWith(); + }); + + it('should fail and log if resume failed', async () => { + mockCurrentTask.resumeRecording.mockRejectedValueOnce(new Error('Resume error')); + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.toggleRecording(false); + }); + + expect(mockCurrentTask.resumeRecording).toHaveBeenCalledWith(); + expect(mockLogger.error).toHaveBeenCalledWith('Error resuming recording: Error: Resume error', expect.any(Object)); + }); + + it('should assign media received from media event to audio tag', async () => { + global.MediaStream = jest.fn().mockImplementation((tracks) => { + return {mockStream: 'mock-stream'}; + }); + const mockAudioElement = {current: {srcObject: null}}; + jest.spyOn(React, 'useRef').mockReturnValue(mockAudioElement); + const mockAudio = { + srcObject: 'mock-audio', + }; + + const {result, unmount} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + act(() => { + mockCurrentTask.on.mock.calls[0][1](mockAudio); + }); + + await waitFor(() => { + expect(mockAudioElement.current).toEqual({srcObject: {mockStream: 'mock-stream'}}); + }); + + // Ensure no errors are logged + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should handle task media event', async () => { + const mockTrack = {kind: 'audio'}; + const mockAudioElement = {current: {srcObject: null}}; + jest.spyOn(React, 'useRef').mockReturnValue(mockAudioElement); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + act(() => { + mockCurrentTask.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_MEDIA)?.[1](mockTrack); + }); + + await waitFor(() => { + expect(mockAudioElement.current.srcObject).toEqual({getTracks: expect.any(Function)}); + }); + + // Ensure no errors are logged + expect(logger.error).not.toHaveBeenCalled(); + }); + + 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(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + // 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(logger.error).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(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + 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(logger.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/contact-center/user-state/src/user-state/index.tsx b/packages/contact-center/user-state/src/user-state/index.tsx index 0fba12c61..c443ca4c2 100644 --- a/packages/contact-center/user-state/src/user-state/index.tsx +++ b/packages/contact-center/user-state/src/user-state/index.tsx @@ -6,15 +6,16 @@ import {useUserState} from '../helper'; import UserStatePresentational from './user-state.presentational'; import {IUserState} from './use-state.types'; -const UserState: React.FunctionComponent = observer(() => { +const UserStateComponent: React.FunctionComponent = () => { const {cc, idleCodes, agentId} = store; const props: IUserState = useUserState({ idleCodes, agentId, - cc + cc, }); return ; -}); +}; +const UserState = observer(UserStateComponent); export {UserState}; 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 750a0df45..3ddb7377f 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -1,5 +1,5 @@ import React, {useState} from 'react'; -import {StationLogin, UserState, IncomingTask, TaskList, store} from '@webex/cc-widgets'; +import {StationLogin, UserState, IncomingTask, TaskList, CallControl, store} from '@webex/cc-widgets'; function App() { const [isSdkReady, setIsSdkReady] = useState(false); @@ -39,6 +39,18 @@ function App() { console.log('onTaskDeclined invoked'); }; + const onHoldResume = () => { + console.log('onHoldResume invoked'); + }; + + const onEnd = () => { + console.log('onEnd invoked'); + }; + + const onWrapup = () => { + console.log('onWrapup invoked'); + }; + return ( <>

Contact Center widgets in a react app

@@ -66,6 +78,7 @@ function App() { + )} diff --git a/widgets-samples/cc/samples-cc-wc-app/app.js b/widgets-samples/cc/samples-cc-wc-app/app.js index dd338033f..feaaf0b07 100644 --- a/widgets-samples/cc/samples-cc-wc-app/app.js +++ b/widgets-samples/cc/samples-cc-wc-app/app.js @@ -4,6 +4,7 @@ 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'); +const ccCallControl = document.createElement('widget-cc-call-control'); if (!ccStationLogin && !ccUserState) { console.error('Failed to find the required elements'); @@ -31,6 +32,9 @@ function initWidgets(){ ccIncomingTask.onDeclined = onDeclined; ccTaskList.onTaskAccepted = onTaskAccepted; ccTaskList.onTaskDeclined = onTaskDeclined; + ccCallControl.onHoldResume = onHoldResume; + ccCallControl.onEnd = onEnd; + ccCallControl.onWrapup = onWrapup; ccStationLogin.classList.remove('disabled'); }).catch((error) => { console.error('Failed to initialize widgets:', error); @@ -43,6 +47,7 @@ function loginSuccess(){ widgetsContainer.appendChild(ccUserState); widgetsContainer.appendChild(ccIncomingTask); widgetsContainer.appendChild(ccTaskList); + widgetsContainer.appendChild(ccCallControl); } function logoutSuccess(){ @@ -64,4 +69,17 @@ function onTaskAccepted(){ function onTaskDeclined(){ console.log('onTaskDeclined invoked'); -}; \ No newline at end of file +}; + +function onHoldResume() { + console.log('onHoldResume invoked'); + } + + function onEnd() { + console.log('onEnd invoked'); + } + + function onWrapup() { + console.log('onWrapUp invoked'); + } + \ No newline at end of file