From 87898e84171da879ad4ce9a5baf6e6f1a608e3ef Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Fri, 17 Jan 2025 12:35:34 +0530 Subject: [PATCH 01/12] fix(call-control): add-call-control-widget --- docs/react-samples/src/App.tsx | 3 +- .../contact-center/cc-widgets/src/index.ts | 4 +- packages/contact-center/store/src/store.ts | 12 +- .../call-control.presentational.tsx | 93 ++++++ .../src/CallControl/call-control.styles.scss | 60 ++++ .../task/src/CallControl/index.tsx | 17 + .../incoming-task.presentational.tsx | 4 +- packages/contact-center/task/src/helper.ts | 126 ++++++- packages/contact-center/task/src/index.ts | 3 +- .../contact-center/task/src/task.types.ts | 35 +- .../call-control.presentational.tsx | 110 ++++++ .../task/tests/CallControl/index.tsx | 46 +++ packages/contact-center/task/tests/helper.ts | 313 ++++++++++++++++-- 13 files changed, 776 insertions(+), 50 deletions(-) create mode 100644 packages/contact-center/task/src/CallControl/call-control.presentational.tsx create mode 100644 packages/contact-center/task/src/CallControl/call-control.styles.scss create mode 100644 packages/contact-center/task/src/CallControl/index.tsx create mode 100644 packages/contact-center/task/tests/CallControl/call-control.presentational.tsx create mode 100644 packages/contact-center/task/tests/CallControl/index.tsx diff --git a/docs/react-samples/src/App.tsx b/docs/react-samples/src/App.tsx index 750a0df45..5d9544d36 100644 --- a/docs/react-samples/src/App.tsx +++ b/docs/react-samples/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); @@ -66,6 +66,7 @@ function App() { + )} 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/store/src/store.ts b/packages/contact-center/store/src/store.ts index 8acdf07fb..099ecb43e 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -20,9 +20,18 @@ class Store implements IStore { idleCodes: IdleCode[] = []; agentId: string = ''; selectedLoginOption: string = ''; + wrapupCodes: any; + currentTask: any = null; constructor() { - makeAutoObservable(this, {cc: observable.ref}); + makeAutoObservable(this, { + cc: observable.ref, + currentTask: observable, // Make currentTask observable + }); + } + + setCurrentTask(task: any): void { + this.currentTask = task; } public static getInstance(): Store { @@ -47,6 +56,7 @@ class Store implements IStore { this.loginOptions = response.loginVoiceOptions; this.idleCodes = response.idleCodes; this.agentId = response.agentId; + this.wrapupCodes = response.wrapupCodes; }).catch((error) => { this.logger.error(`Error registering contact center: ${error}`, { module: 'cc-store#store.ts', 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..adf14e02e --- /dev/null +++ b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx @@ -0,0 +1,93 @@ +import React, {useState} from 'react'; +import './call-control.styles.scss'; +import {CallControlPresentationalProps} from '../task.types'; + +const CallControlPresentational = (props: CallControlPresentationalProps) => { + const [isHeld, setIsHeld] = useState(false); + const [isRecordingPaused, setIsRecordingPaused] = useState(true); + const [selectedWrapupReason, setSelectedWrapupReason] = useState(null); + const [selectedWrapupId, setSelectedWrapupId] = useState(null); + + const {currentTask, holdResume, pauseResumeRecording, endCall, wrapupCall, wrapupCodes, wrapupRequired} = props; + const handleHoldResume = () => { + if (isHeld) { + holdResume(false); + } else { + holdResume(true); + } + setIsHeld(!isHeld); + }; + + const handlePauseResumeRecording = () => { + if (isRecordingPaused) { + pauseResumeRecording(true); + } else { + pauseResumeRecording(false); + } + setIsRecordingPaused(!isRecordingPaused); + }; + + const handleEndCall = () => { + endCall(); + }; + + const handleWrapupCall = () => { + if (selectedWrapupReason && selectedWrapupId) { + wrapupCall(selectedWrapupReason, selectedWrapupId); + } + }; + + const handleWrapupChange = (event: React.ChangeEvent) => { + const selectedOption = event.target.options[event.target.selectedIndex]; + setSelectedWrapupReason(selectedOption.text); + setSelectedWrapupId(selectedOption.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..855cb8905 --- /dev/null +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import store from '@webex/cc-store'; +import {observer} from 'mobx-react-lite'; + +import {useCallControl} from '../helper'; +import CallControlPresentational from './call-control.presentational'; +import {CallControlProps} from '../task.types'; + +const CallControl: React.FunctionComponent = observer(({onHoldResume, onEnd, onWrapUp}) => { + const {logger, currentTask, wrapupCodes} = store; + + const result = {...useCallControl({currentTask, onHoldResume, onEnd, onWrapUp, logger}), wrapupCodes}; + + return ; +}); + +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..afc2472fa 100644 --- a/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx +++ b/packages/contact-center/task/src/IncomingTask/incoming-task.presentational.tsx @@ -120,9 +120,9 @@ const styles: {[key: string]: React.CSSProperties} = { }; const IncomingTaskPresentational: React.FunctionComponent = (props) => { - const {currentTask, accept, decline, isBrowser, audioRef} = props; + const {currentTask, accept, decline, isBrowser, audioRef, isAnswered} = props; - if (!currentTask) { + if (!currentTask || isAnswered) { return <>; // hidden component } diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index c2ad49dd2..dcab17059 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 {TASK_EVENTS, useCallControlProps, UseTaskListProps, UseTaskProps} from './task.types'; import {ITask} from '@webex/plugin-cc'; +import store from '@webex/cc-store'; // Hook for managing the task list export const useTaskList = (props: UseTaskListProps) => { @@ -46,6 +47,7 @@ export const useTaskList = (props: UseTaskListProps) => { task .accept(taskId) .then(() => { + store.setCurrentTask(task); onTaskAccepted && onTaskAccepted(task); }) .catch((error: Error) => { @@ -64,6 +66,7 @@ export const useTaskList = (props: UseTaskListProps) => { .decline(taskId) .then(() => { onTaskDeclined && onTaskDeclined(task); + store.setCurrentTask(null); }) .catch((error: Error) => { logger.error(`Error declining task: ${error}`, { @@ -91,7 +94,6 @@ export const useIncomingTask = (props: UseTaskProps) => { 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(() => { @@ -103,11 +105,6 @@ export const useIncomingTask = (props: UseTaskProps) => { setCurrentTask(null); }, []); - const handleTaskMissed = useCallback(() => { - setIsMissed(true); - setCurrentTask(null); - }, []); - const handleTaskMedia = useCallback((track) => { if (audioRef.current) { audioRef.current.srcObject = new MediaStream([track]); @@ -116,6 +113,8 @@ export const useIncomingTask = (props: UseTaskProps) => { const handleIncomingTask = useCallback((task: ITask) => { setCurrentTask(task); + setIsAnswered(false); + setIsEnded(false); }, []); useEffect(() => { @@ -124,7 +123,6 @@ export const useIncomingTask = (props: UseTaskProps) => { 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); } @@ -133,11 +131,10 @@ export const useIncomingTask = (props: UseTaskProps) => { 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]); + }, [cc, currentTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded, handleTaskMedia]); const accept = () => { const taskId = currentTask?.data.interactionId; @@ -146,6 +143,7 @@ export const useIncomingTask = (props: UseTaskProps) => { currentTask .accept(taskId) .then(() => { + store.setCurrentTask(currentTask); onAccepted && onAccepted(); }) .catch((error: Error) => { @@ -164,6 +162,7 @@ export const useIncomingTask = (props: UseTaskProps) => { .decline(taskId) .then(() => { setCurrentTask(null); + store.setCurrentTask(null); onDeclined && onDeclined(); }) .catch((error: Error) => { @@ -181,10 +180,115 @@ export const useIncomingTask = (props: UseTaskProps) => { setCurrentTask, isAnswered, isEnded, - isMissed, accept, decline, isBrowser, audioRef, }; }; + +export const useCallControl = (props: useCallControlProps) => { + const {currentTask, onHoldResume, onEnd, onWrapUp, logger} = props; + const [wrapupRequired, setWrapupRequired] = useState(false); + + const handleTaskEnded = useCallback((wrapupRequired: boolean) => { + setWrapupRequired(wrapupRequired); + }, []); + + useEffect(() => { + if (currentTask) { + currentTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); + } + + return () => { + if (currentTask) { + currentTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); + } + }; + }, [currentTask, handleTaskEnded]); + + const holdResume = (hold: boolean) => { + if (hold) { + currentTask + .hold() + .then(() => { + onHoldResume(); + }) + .catch((error: Error) => { + logger.error(`Error holding call: ${error}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#holdResume', + }); + }); + } else { + currentTask + .resume() + .then(() => { + onHoldResume(); + }) + .catch((error: Error) => { + logger.error(`Error resuming call: ${error}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#holdResume', + }); + }); + } + }; + + const pauseResumeRecording = (pause: boolean) => { + if (pause) { + currentTask.pauseRecording().catch((error: Error) => { + logger.error(`Error pausing recording: ${error}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#pauseResumeRecording', + }); + }); + } else { + currentTask.resumeRecording().catch((error: Error) => { + logger.error(`Error resuming recording: ${error}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#pauseResumeRecording', + }); + }); + } + }; + + const endCall = () => { + currentTask + .end() + .then(() => { + onEnd(); + }) + .catch((error: Error) => { + logger.error(`Error ending call: ${error}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#endCall', + }); + }); + }; + + const wrapupCall = (wrapUpReason, auxCodeId) => { + currentTask + .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) + .then(() => { + setWrapupRequired(false); + store.setCurrentTask(null); + onWrapUp(); + }) + .catch((error: Error) => { + logger.error(`Error ending call: ${error}`, { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#endCall', + }); + }); + }; + + return { + currentTask, + endCall, + holdResume, + pauseResumeRecording, + 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..e161898b7 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -70,11 +70,6 @@ export interface TaskProps { */ isEnded: boolean; - /** - * Flag to determine if the task is missed - */ - isMissed: boolean; - /** * Selected login option */ @@ -97,10 +92,13 @@ 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' + 'currentTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' | 'audioRef' >; export type IncomingTaskProps = Pick; export type TaskListProps = Pick; @@ -121,3 +119,26 @@ export enum TASK_EVENTS { TASK_END = 'task:end', TASK_WRAPUP = 'task:wrapup', } // TODO: remove this once cc sdk exports this enum + +export interface ControlProps { + currentTask: ITask; + onHoldResume: () => void; + onEnd: () => void; + onWrapUp: () => void; + logger: ILogger; + wrapupCodes: any[]; + wrapupRequired: boolean; + holdResume: (hold: boolean) => void; + pauseResumeRecording: (pause: boolean) => void; + endCall: () => void; + wrapupCall: (wrapupReason: string, wrapupId: string) => void; +} + +export type CallControlProps = Pick; + +export type CallControlPresentationalProps = Pick< + ControlProps, + 'currentTask' | 'wrapupCodes' | 'wrapupRequired' | 'holdResume' | 'pauseResumeRecording' | '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..6a4d7421d --- /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 mockHoldResume = jest.fn(); + const mockPauseResumeRecording = 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: {}, + holdResume: mockHoldResume, + pauseResumeRecording: mockPauseResumeRecording, + 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('Resume Recording')).toBeInTheDocument(); + expect(screen.getByText('End')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('Wrap Up')).toBeInTheDocument(); + }); + + it('calls holdResume with the correct argument when Hold/Resume button is clicked', () => { + render(); + + const holdButton = screen.getByText('Hold'); + fireEvent.click(holdButton); + + expect(mockHoldResume).toHaveBeenCalledWith(true); + + fireEvent.click(holdButton); + expect(mockHoldResume).toHaveBeenCalledWith(false); + }); + + it('calls pauseResumeRecording with the correct argument when Pause/Resume Recording button is clicked', () => { + render(); + + const pauseButton = screen.getByText('Resume Recording'); + fireEvent.click(pauseButton); + + expect(mockPauseResumeRecording).toHaveBeenCalledWith(true); + + fireEvent.click(pauseButton); + expect(mockPauseResumeRecording).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('Resume 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/helper.ts b/packages/contact-center/task/tests/helper.ts index 311c8eced..cf978cdfe 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,5 +1,5 @@ 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'; // Mock webex instance and task @@ -418,30 +418,6 @@ describe('useTaskList Hook', () => { // 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:''}) - ); - - // 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(logger.error).not.toHaveBeenCalled(); - }); }); describe('useIncomingTask Hook - handleTaskMedia', () => { @@ -531,3 +507,290 @@ describe('useTaskList Hook', () => { }); }); }); + + +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(() => { + jest.clearAllMocks(); + }); + + it('should set up and clean up event listeners on currentTask', () => { + renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + 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, + }) + ); + + 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.holdResume(true); + }); + + expect(mockCurrentTask.hold).toHaveBeenCalled(); + expect(mockOnHoldResume).toHaveBeenCalled(); + }); + + 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, + }) + ); + + await act(async () => { + await result.current.holdResume(false); + }); + + 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.holdResume(true); + }); + + 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.holdResume(false); + }); + + expect(mockLogger.error).toHaveBeenCalledWith('Error resuming call: Error: Resume error', expect.any(Object)); + }); + + it('should call endCall and handle success', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await result.current.endCall(); + }); + + expect(mockCurrentTask.end).toHaveBeenCalled(); + expect(mockOnEnd).toHaveBeenCalled(); + }); + + 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, + }) + ); + + await act(async () => { + await result.current.endCall(); + }); + + expect(mockCurrentTask.end).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith('Error ending call: Error: End error', expect.any(Object)); + }); + + it('should call wrapupCall and handle success', async () => { + 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(mockCurrentTask.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: 123}); + expect(mockOnWrapUp).toHaveBeenCalled(); + }); + + it('should log an error if wrapup fails', async () => { + mockCurrentTask.wrapup.mockRejectedValueOnce(new Error('Wrapup error')); + + 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 ending 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.pauseResumeRecording(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.pauseResumeRecording(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.pauseResumeRecording(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.pauseResumeRecording(false); + }); + + expect(mockCurrentTask.resumeRecording).toHaveBeenCalledWith(); + expect(mockLogger.error).toHaveBeenCalledWith('Error resuming recording: Error: Resume error', expect.any(Object)); + }); +}); + From a0d12374f6118f634a5b2c96b45643216dc4d362 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Fri, 17 Jan 2025 12:40:23 +0530 Subject: [PATCH 02/12] fix(call-control): add-web-component-for-call-control --- packages/contact-center/cc-widgets/src/wc.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index 4daa332d4..fff9531a7 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,6 +26,14 @@ const WebStationLogin = r2wc(StationLogin, { }, }); +const WebCallControl = r2wc(CallControl, { + props: { + onHoldResume: 'function', + onEnd: 'function', + onWrapUp: 'function', + }, +}); + if (!customElements.get('widget-cc-user-state')) { customElements.define('widget-cc-user-state', WebUserState); } @@ -42,4 +50,8 @@ if (!customElements.get('widget-cc-task-list')) { customElements.define('widget-cc-task-list', WebTaskList); } +if (!customElements.get('widget-cc-call-control')) { + customElements.define('widget-cc-call-control', WebCallControl); +} + export {store}; From 8b3eadb0433b96ba36df91601bb1346863d397a6 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Mon, 20 Jan 2025 14:19:36 +0530 Subject: [PATCH 03/12] fix(call-control): review-comments --- docs/react-samples/src/App.tsx | 14 +++- docs/web-component-samples/app.js | 20 ++++- packages/contact-center/cc-widgets/src/wc.ts | 2 +- packages/contact-center/store/src/store.ts | 8 +- .../contact-center/store/src/store.types.ts | 6 ++ .../call-control.presentational.tsx | 12 +-- .../task/src/TaskList/index.tsx | 8 +- .../src/TaskList/task-list.presentational.tsx | 30 ++++---- packages/contact-center/task/src/helper.ts | 11 +-- .../contact-center/task/src/task.types.ts | 9 ++- .../call-control.presentational.tsx | 10 +-- packages/contact-center/task/tests/helper.ts | 75 +++++++++++++------ 12 files changed, 141 insertions(+), 64 deletions(-) diff --git a/docs/react-samples/src/App.tsx b/docs/react-samples/src/App.tsx index 5d9544d36..3ddb7377f 100644 --- a/docs/react-samples/src/App.tsx +++ b/docs/react-samples/src/App.tsx @@ -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,7 +78,7 @@ function App() { - + )} diff --git a/docs/web-component-samples/app.js b/docs/web-component-samples/app.js index dd338033f..feaaf0b07 100644 --- a/docs/web-component-samples/app.js +++ b/docs/web-component-samples/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 diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index fff9531a7..0e6e5da54 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -30,7 +30,7 @@ const WebCallControl = r2wc(CallControl, { props: { onHoldResume: 'function', onEnd: 'function', - onWrapUp: 'function', + onWrapup: 'function', }, }); diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 099ecb43e..4f6cff9b1 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, + WrapupCode } from './store.types'; +import {ITask} from '@webex/plugin-cc'; class Store implements IStore { private static instance: Store; @@ -20,8 +22,8 @@ class Store implements IStore { idleCodes: IdleCode[] = []; agentId: string = ''; selectedLoginOption: string = ''; - wrapupCodes: any; - currentTask: any = null; + wrapupCodes: WrapupCode[] = []; + currentTask: ITask = null; constructor() { makeAutoObservable(this, { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 1e8b3c6ce..ee8a4b191 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 WrapupCode { + id: string; + name: string; + } + export type { IContactCenter, @@ -48,4 +53,5 @@ export type { InitParams, IStore, ILogger, + WrapupCode } diff --git a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx index adf14e02e..fb14be78f 100644 --- a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx +++ b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx @@ -1,10 +1,11 @@ import React, {useState} from 'react'; import './call-control.styles.scss'; import {CallControlPresentationalProps} from '../task.types'; +import {WrapupCodes} from '@webex/cc-store'; const CallControlPresentational = (props: CallControlPresentationalProps) => { const [isHeld, setIsHeld] = useState(false); - const [isRecordingPaused, setIsRecordingPaused] = useState(true); + const [isRecording, setIsRecording] = useState(true); const [selectedWrapupReason, setSelectedWrapupReason] = useState(null); const [selectedWrapupId, setSelectedWrapupId] = useState(null); @@ -19,12 +20,12 @@ const CallControlPresentational = (props: CallControlPresentationalProps) => { }; const handlePauseResumeRecording = () => { - if (isRecordingPaused) { + if (isRecording) { pauseResumeRecording(true); } else { pauseResumeRecording(false); } - setIsRecordingPaused(!isRecordingPaused); + setIsRecording(!isRecording); }; const handleEndCall = () => { @@ -32,6 +33,7 @@ const CallControlPresentational = (props: CallControlPresentationalProps) => { }; const handleWrapupCall = () => { + setSelectedWrapupReason(''); if (selectedWrapupReason && selectedWrapupId) { wrapupCall(selectedWrapupReason, selectedWrapupId); } @@ -56,7 +58,7 @@ const CallControlPresentational = (props: CallControlPresentationalProps) => { {isHeld ? 'Resume' : 'Hold'} - - - )} - + {!currentTask && ( +
+

{dn}

+ {isBrowser && ( +
+ + +
+ )} +
+ )} ); })} diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index dcab17059..80cfc05cd 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -191,7 +191,8 @@ export const useCallControl = (props: useCallControlProps) => { const {currentTask, onHoldResume, onEnd, onWrapUp, logger} = props; const [wrapupRequired, setWrapupRequired] = useState(false); - const handleTaskEnded = useCallback((wrapupRequired: boolean) => { + const handleTaskEnded = useCallback((args: {wrapupRequired: boolean}) => { + const {wrapupRequired} = args; setWrapupRequired(wrapupRequired); }, []); @@ -235,8 +236,8 @@ export const useCallControl = (props: useCallControlProps) => { } }; - const pauseResumeRecording = (pause: boolean) => { - if (pause) { + const pauseResumeRecording = (resume: boolean) => { + if (resume) { currentTask.pauseRecording().catch((error: Error) => { logger.error(`Error pausing recording: ${error}`, { module: 'widget-cc-task#helper.ts', @@ -276,9 +277,9 @@ export const useCallControl = (props: useCallControlProps) => { onWrapUp(); }) .catch((error: Error) => { - logger.error(`Error ending call: ${error}`, { + logger.error(`Error wrapping up call: ${error}`, { module: 'widget-cc-task#helper.ts', - method: 'useCallControl#endCall', + method: 'useCallControl#wrapupCall', }); }); }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index e161898b7..015c86050 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. @@ -103,7 +103,10 @@ export type IncomingTaskPresentationalProps = Pick< 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', @@ -126,7 +129,7 @@ export interface ControlProps { onEnd: () => void; onWrapUp: () => void; logger: ILogger; - wrapupCodes: any[]; + wrapupCodes: WrapupCodes[]; wrapupRequired: boolean; holdResume: (hold: boolean) => void; pauseResumeRecording: (pause: boolean) => void; diff --git a/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx b/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx index 6a4d7421d..ad453c40e 100644 --- a/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx +++ b/packages/contact-center/task/tests/CallControl/call-control.presentational.tsx @@ -31,13 +31,13 @@ describe('CallControlPresentational', () => { render(); expect(screen.getByText('Hold')).toBeInTheDocument(); - expect(screen.getByText('Resume Recording')).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 holdResume with the correct argument when Hold/Resume button is clicked', () => { + it('calls holdResume with the correct argument when Hold/Pause button is clicked', () => { render(); const holdButton = screen.getByText('Hold'); @@ -49,10 +49,10 @@ describe('CallControlPresentational', () => { expect(mockHoldResume).toHaveBeenCalledWith(false); }); - it('calls pauseResumeRecording with the correct argument when Pause/Resume Recording button is clicked', () => { + it('calls pauseResumeRecording with the correct argument when Pause/Pause Recording button is clicked', () => { render(); - const pauseButton = screen.getByText('Resume Recording'); + const pauseButton = screen.getByText('Pause Recording'); fireEvent.click(pauseButton); expect(mockPauseResumeRecording).toHaveBeenCalledWith(true); @@ -87,7 +87,7 @@ describe('CallControlPresentational', () => { render(); const holdButton = screen.getByText('Hold'); - const pauseButton = screen.getByText('Resume Recording'); + const pauseButton = screen.getByText('Pause Recording'); const endButton = screen.getByText('End'); const select = screen.getByRole('combobox'); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index cf978cdfe..88d4cf452 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -24,11 +24,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 +120,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 +151,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 +177,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 +198,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 +219,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 +250,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 +275,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 +290,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 +307,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 +324,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 +343,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 +361,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 +377,6 @@ describe('useTaskList Hook', () => { }); describe('useIncomingTask Hook - Task Events', () => { - afterEach(() => { jest.clearAllMocks(); logger.error.mockRestore(); @@ -375,7 +384,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 +413,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 @@ -421,7 +444,6 @@ describe('useTaskList Hook', () => { }); describe('useIncomingTask Hook - handleTaskMedia', () => { - beforeEach(() => { // Mock the MediaStreamTrack and MediaStream classes for the test environment global.MediaStreamTrack = jest.fn().mockImplementation(() => ({ @@ -447,7 +469,14 @@ describe('useTaskList Hook', () => { }; const {result} = renderHook(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger, selectedLoginOption:''}) + useIncomingTask({ + cc: ccMock, + onAccepted, + onDeclined, + selectedLoginOption: 'BROWSER', + logger, + selectedLoginOption: '', + }) ); // Manually assign the mocked audio element to the ref @@ -479,7 +508,7 @@ describe('useTaskList Hook', () => { 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, }) + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) ); result.current.audioRef.current = null; @@ -508,7 +537,6 @@ describe('useTaskList Hook', () => { }); }); - describe('useCallControl', () => { const mockCurrentTask = { on: jest.fn(), @@ -715,7 +743,7 @@ describe('useCallControl', () => { await result.current.wrapupCall('Wrap reason', 123); }); - expect(mockLogger.error).toHaveBeenCalledWith('Error ending call: Error: Wrapup error', expect.any(Object)); + 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 () => { @@ -793,4 +821,3 @@ describe('useCallControl', () => { expect(mockLogger.error).toHaveBeenCalledWith('Error resuming recording: Error: Resume error', expect.any(Object)); }); }); - From 838b157ea22f2733d0e3d62a81f98dac3394f9f9 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Mon, 20 Jan 2025 14:44:29 +0530 Subject: [PATCH 04/12] fix(call-control): fix-uts --- packages/contact-center/store/tests/store.ts | 47 ++++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/contact-center/store/tests/store.ts b/packages/contact-center/store/tests/store.ts index a0f6d6029..a0d3f09dc 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', () => { @@ -43,16 +43,16 @@ describe('Store', () => { it('should initialize with default values', () => { expect(store.teams).toEqual([]); expect(store.loginOptions).toEqual([]); - 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 }], - agentId: 'agent1' + idleCodes: [{id: 'code1', name: 'Code 1', isSystem: false, isDefault: false}], + agentId: 'agent1', }; mockWebex.cc.register.mockResolvedValue(mockResponse); @@ -70,12 +70,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', }); } }); @@ -83,7 +82,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(); @@ -95,8 +94,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(); @@ -104,15 +103,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')); @@ -122,8 +121,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; @@ -137,4 +136,4 @@ describe('Store', () => { await expect(initPromise).rejects.toThrow('Webex SDK failed to initialize'); }); }); -}); \ No newline at end of file +}); From 888b549c839cc0a8892b6b8fcab86c1f2566a80d Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Tue, 21 Jan 2025 00:32:52 +0530 Subject: [PATCH 05/12] fix(call-control): review-comments --- packages/contact-center/store/src/store.ts | 2 +- .../incoming-task.presentational.tsx | 6 +- packages/contact-center/task/src/helper.ts | 60 +++++----- .../contact-center/task/src/task.types.ts | 58 +++++++++- .../incoming-task.presentational.tsx | 2 +- packages/contact-center/task/tests/helper.ts | 109 ++++++++++++++++-- 6 files changed, 192 insertions(+), 45 deletions(-) diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 4f6cff9b1..e716b333c 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -32,7 +32,7 @@ class Store implements IStore { }); } - setCurrentTask(task: any): void { + setCurrentTask(task: ITask): void { this.currentTask = task; } 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 afc2472fa..8d514ef83 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, isAnswered} = props; + const {incomingTask, accept, decline, isBrowser, audioRef, isAnswered} = props; - if (!currentTask || isAnswered) { + 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 diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 80cfc05cd..64734fa88 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -91,18 +91,19 @@ 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 audioRef = useRef(null); // Ref for the audio element const handleTaskAssigned = useCallback(() => { + store.setCurrentTask(incomingTask); setIsAnswered(true); }, []); const handleTaskEnded = useCallback(() => { setIsEnded(true); - setCurrentTask(null); + setIncomingTask(null); }, []); const handleTaskMedia = useCallback((track) => { @@ -112,7 +113,7 @@ export const useIncomingTask = (props: UseTaskProps) => { }, []); const handleIncomingTask = useCallback((task: ITask) => { - setCurrentTask(task); + setIncomingTask(task); setIsAnswered(false); setIsEnded(false); }, []); @@ -120,30 +121,30 @@ export const useIncomingTask = (props: UseTaskProps) => { 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_MEDIA, handleTaskMedia); + if (incomingTask) { + incomingTask.on(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); + incomingTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); + incomingTask.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_MEDIA, handleTaskMedia); + if (incomingTask) { + incomingTask.off(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); + incomingTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); + incomingTask.off(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); } }; - }, [cc, currentTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded, handleTaskMedia]); + }, [cc, incomingTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded, handleTaskMedia]); const accept = () => { - const taskId = currentTask?.data.interactionId; + const taskId = incomingTask?.data.interactionId; if (!taskId) return; - currentTask + incomingTask .accept(taskId) .then(() => { - store.setCurrentTask(currentTask); + store.setCurrentTask(incomingTask); onAccepted && onAccepted(); }) .catch((error: Error) => { @@ -155,13 +156,13 @@ export const useIncomingTask = (props: UseTaskProps) => { }; 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(); }) @@ -176,8 +177,7 @@ export const useIncomingTask = (props: UseTaskProps) => { const isBrowser = selectedLoginOption === 'BROWSER'; return { - currentTask, - setCurrentTask, + incomingTask, isAnswered, isEnded, accept, @@ -208,12 +208,12 @@ export const useCallControl = (props: useCallControlProps) => { }; }, [currentTask, handleTaskEnded]); - const holdResume = (hold: boolean) => { + const toggleHold = (hold: boolean) => { if (hold) { currentTask .hold() .then(() => { - onHoldResume(); + if (onHoldResume) onHoldResume(); }) .catch((error: Error) => { logger.error(`Error holding call: ${error}`, { @@ -225,7 +225,7 @@ export const useCallControl = (props: useCallControlProps) => { currentTask .resume() .then(() => { - onHoldResume(); + if (onHoldResume) onHoldResume(); }) .catch((error: Error) => { logger.error(`Error resuming call: ${error}`, { @@ -236,8 +236,8 @@ export const useCallControl = (props: useCallControlProps) => { } }; - const pauseResumeRecording = (resume: boolean) => { - if (resume) { + const toggleRecording = (pause: boolean) => { + if (pause) { currentTask.pauseRecording().catch((error: Error) => { logger.error(`Error pausing recording: ${error}`, { module: 'widget-cc-task#helper.ts', @@ -258,7 +258,7 @@ export const useCallControl = (props: useCallControlProps) => { currentTask .end() .then(() => { - onEnd(); + if (onEnd) onEnd(); }) .catch((error: Error) => { logger.error(`Error ending call: ${error}`, { @@ -268,13 +268,13 @@ export const useCallControl = (props: useCallControlProps) => { }); }; - const wrapupCall = (wrapUpReason, auxCodeId) => { + const wrapupCall = (wrapUpReason: string, auxCodeId: string) => { currentTask .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) .then(() => { setWrapupRequired(false); store.setCurrentTask(null); - onWrapUp(); + if (onWrapUp) onWrapUp(); }) .catch((error: Error) => { logger.error(`Error wrapping up call: ${error}`, { @@ -287,8 +287,8 @@ export const useCallControl = (props: useCallControlProps) => { return { currentTask, endCall, - holdResume, - pauseResumeRecording, + toggleHold, + toggleRecording, wrapupCall, wrapupRequired, }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 015c86050..7b777eb1e 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -10,6 +10,11 @@ export interface TaskProps { */ currentTask: ITask; + /** + * Incoming task on the incoming task widget + */ + incomingTask: ITask; + /** * CC SDK Instance. */ @@ -98,7 +103,7 @@ export type UseTaskListProps = Pick< >; export type IncomingTaskPresentationalProps = Pick< TaskProps, - 'currentTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' | 'audioRef' + 'incomingTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' | 'audioRef' >; export type IncomingTaskProps = Pick; export type TaskListProps = Pick; @@ -123,17 +128,68 @@ export enum TASK_EVENTS { 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 { + /** + * 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). + */ holdResume: (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. + */ pauseResumeRecording: (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; } 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 88d4cf452..9857b2196 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, useCallControl} from '../src/helper'; import {TASK_EVENTS} from '../src/task.types'; +import React from 'react'; // Mock webex instance and task const ccMock = { @@ -113,6 +114,35 @@ describe('useIncomingTask Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + 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(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) + ); + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + act(() => { + taskMock.on.mock.calls[2][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 errors when accepting a task', async () => { const failingTask = { ...taskMock, @@ -174,6 +204,31 @@ describe('useIncomingTask Hook', () => { method: 'useIncomingTask#decline', }); }); + + 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(() => + useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) + ); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + act(() => { + taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_MEDIA)?.[1](mockTrack); + }); + + await waitFor(() => { + expect(mockAudioElement.current.srcObject).toEqual(new MediaStream([mockTrack])); + }); + + // Ensure no errors are logged + expect(logger.error).not.toHaveBeenCalled(); + }); }); describe('useTaskList Hook', () => { @@ -376,6 +431,25 @@ describe('useTaskList Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + it('should remove a task from the list when it is unassigned', async () => { + const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); + + act(() => { + ccMock.on.mock.calls[0][1](taskMock); + }); + + act(() => { + taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_UNASSIGNED)?.[1](); + }); + + await waitFor(() => { + expect(result.current.taskList).not.toContain(taskMock); + }); + + // Ensure no errors are logged + expect(logger.error).not.toHaveBeenCalled(); + }); + describe('useIncomingTask Hook - Task Events', () => { afterEach(() => { jest.clearAllMocks(); @@ -435,7 +509,7 @@ 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 @@ -602,7 +676,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.holdResume(true); + await result.current.toggleHold(true); }); expect(mockCurrentTask.hold).toHaveBeenCalled(); @@ -621,7 +695,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.holdResume(false); + await result.current.toggleHold(false); }); expect(mockCurrentTask.resume).toHaveBeenCalled(); @@ -642,7 +716,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.holdResume(true); + await result.current.toggleHold(true); }); expect(mockLogger.error).toHaveBeenCalledWith('Error holding call: Error: Hold error', expect.any(Object)); @@ -662,7 +736,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.holdResume(false); + await result.current.toggleHold(false); }); expect(mockLogger.error).toHaveBeenCalledWith('Error resuming call: Error: Resume error', expect.any(Object)); @@ -687,6 +761,23 @@ describe('useCallControl', () => { expect(mockOnEnd).toHaveBeenCalled(); }); + it('should update wrapupRequired on TASK_END event', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + }) + ); + + await act(async () => { + await mockCurrentTask.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_END)?.[1]({wrapupRequired: true}); + }); + expect(result.current.wrapupRequired).toBe(true); + }); + it('should call endCall and handle failure', async () => { mockCurrentTask.end.mockRejectedValueOnce(new Error('End error')); const {result} = renderHook(() => @@ -758,7 +849,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.pauseResumeRecording(true); + await result.current.toggleRecording(true); }); expect(mockCurrentTask.pauseRecording).toHaveBeenCalledWith(); @@ -777,7 +868,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.pauseResumeRecording(true); + await result.current.toggleRecording(true); }); expect(mockLogger.error).toHaveBeenCalledWith('Error pausing recording: Error: Pause error', expect.any(Object)); @@ -795,7 +886,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.pauseResumeRecording(false); + await result.current.toggleRecording(false); }); expect(mockCurrentTask.resumeRecording).toHaveBeenCalledWith(); @@ -814,7 +905,7 @@ describe('useCallControl', () => { ); await act(async () => { - await result.current.pauseResumeRecording(false); + await result.current.toggleRecording(false); }); expect(mockCurrentTask.resumeRecording).toHaveBeenCalledWith(); From 4231874e08aa50f8399a086543ca6ec5d7921544 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Tue, 21 Jan 2025 10:41:15 +0530 Subject: [PATCH 06/12] fix(call-control): fix-types --- .../call-control.presentational.tsx | 18 +++++++++--------- packages/contact-center/task/src/task.types.ts | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx index fb14be78f..f7f4e3223 100644 --- a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx +++ b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx @@ -9,21 +9,21 @@ const CallControlPresentational = (props: CallControlPresentationalProps) => { const [selectedWrapupReason, setSelectedWrapupReason] = useState(null); const [selectedWrapupId, setSelectedWrapupId] = useState(null); - const {currentTask, holdResume, pauseResumeRecording, endCall, wrapupCall, wrapupCodes, wrapupRequired} = props; - const handleHoldResume = () => { + const {currentTask, toggleHold, toggleRecording, endCall, wrapupCall, wrapupCodes, wrapupRequired} = props; + const handletoggleHold = () => { if (isHeld) { - holdResume(false); + toggleHold(false); } else { - holdResume(true); + toggleHold(true); } setIsHeld(!isHeld); }; - const handlePauseResumeRecording = () => { + const handletoggleRecording = () => { if (isRecording) { - pauseResumeRecording(true); + toggleRecording(true); } else { - pauseResumeRecording(false); + toggleRecording(false); } setIsRecording(!isRecording); }; @@ -54,10 +54,10 @@ const CallControlPresentational = (props: CallControlPresentationalProps) => { Call Control
- - - - -
-
- + + {wrapupCodes.map((wrapup: WrapupCodes) => ( + - {wrapupCodes.map((wrapup: WrapupCodes) => ( - - ))} - - -
+ ))} + +
- - - - )} + + + + ); -}; +} export default CallControlPresentational; diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 855cb8905..8df865546 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; +import store from '@webex/cc-store'; import {useCallControl} from '../helper'; -import CallControlPresentational from './call-control.presentational'; import {CallControlProps} from '../task.types'; +import CallControlPresentational from './call-control.presentational'; const CallControl: React.FunctionComponent = observer(({onHoldResume, onEnd, onWrapUp}) => { const {logger, currentTask, wrapupCodes} = store; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index a17e1f359..1f335bca4 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -1,7 +1,7 @@ import {useState, useEffect, useCallback, useRef} from 'react'; -import {TASK_EVENTS, useCallControlProps, 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) => { @@ -16,7 +16,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); @@ -32,7 +31,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]; }); @@ -200,59 +198,52 @@ export const useCallControl = (props: useCallControlProps) => { }, []); useEffect(() => { - if (currentTask) { - currentTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); - } + if (!currentTask) return; + + currentTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); return () => { - if (currentTask) { - currentTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); - } + currentTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); }; }, [currentTask, handleTaskEnded]); const toggleHold = (hold: boolean) => { + const logLocation = { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#holdResume', + }; + if (hold) { currentTask .hold() - .then(() => { - if (onHoldResume) onHoldResume(); - }) - .catch((error: Error) => { - logger.error(`Error holding call: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useCallControl#holdResume', - }); - }); - } else { - currentTask - .resume() - .then(() => { - if (onHoldResume) onHoldResume(); - }) + .then(() => onHoldResume && onHoldResume()) .catch((error: Error) => { - logger.error(`Error resuming call: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useCallControl#holdResume', - }); + logger.error(`Error holding call: ${error}`, logLocation); }); + + return; } + + currentTask + .resume() + .then(() => onHoldResume && onHoldResume()) + .catch((error: Error) => { + logger.error(`Error resuming call: ${error}`, logLocation); + }); }; const toggleRecording = (pause: boolean) => { + const logLocation = { + module: 'widget-cc-task#helper.ts', + method: 'useCallControl#pauseResumeRecording', + }; if (pause) { currentTask.pauseRecording().catch((error: Error) => { - logger.error(`Error pausing recording: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useCallControl#pauseResumeRecording', - }); + logger.error(`Error pausing recording: ${error}`, logLocation); }); } else { currentTask.resumeRecording().catch((error: Error) => { - logger.error(`Error resuming recording: ${error}`, { - module: 'widget-cc-task#helper.ts', - method: 'useCallControl#pauseResumeRecording', - }); + logger.error(`Error resuming recording: ${error}`, logLocation); }); } }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 702da1974..513566885 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -116,7 +116,6 @@ 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', diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 9857b2196..4a548a459 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -431,25 +431,6 @@ describe('useTaskList Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should remove a task from the list when it is unassigned', async () => { - const {result} = renderHook(() => useTaskList({cc: ccMock, logger, selectedLoginOption: ''})); - - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_UNASSIGNED)?.[1](); - }); - - await waitFor(() => { - expect(result.current.taskList).not.toContain(taskMock); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); - describe('useIncomingTask Hook - Task Events', () => { afterEach(() => { jest.clearAllMocks(); From 7357bbf2f25e041f7ecfe227d88b3e877cd9d03d Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Tue, 28 Jan 2025 10:17:39 +0530 Subject: [PATCH 09/12] fix(cc-widgets): code-refactor-for-observer-hook --- .../station-login/src/station-login/index.tsx | 5 +- .../call-control.presentational.tsx | 4 +- .../task/src/CallControl/index.tsx | 9 +-- .../task/src/IncomingTask/index.tsx | 5 +- .../task/src/TaskList/index.tsx | 5 +- packages/contact-center/task/src/helper.ts | 67 +++++++++---------- .../user-state/src/user-state/index.tsx | 7 +- 7 files changed, 51 insertions(+), 51 deletions(-) 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/task/src/CallControl/call-control.presentational.tsx b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx index 6a04fa601..c58321b0b 100644 --- a/packages/contact-center/task/src/CallControl/call-control.presentational.tsx +++ b/packages/contact-center/task/src/CallControl/call-control.presentational.tsx @@ -56,9 +56,7 @@ function CallControlPresentational(props: CallControlPresentationalProps) {
+ + {wrapupCodes.map((wrapup: WrapupCodes) => ( + + ))} + + +
-
- - -
- - - - + + + + )} ); } 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 8d514ef83..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,7 +120,7 @@ const styles: {[key: string]: React.CSSProperties} = { }; const IncomingTaskPresentational: React.FunctionComponent = (props) => { - const {incomingTask, accept, decline, isBrowser, audioRef, isAnswered} = props; + const {incomingTask, accept, decline, isBrowser, isAnswered} = props; if (!incomingTask || isAnswered) { return <>; // hidden component @@ -172,7 +172,6 @@ const IncomingTaskPresentational: React.FunctionComponent )} - {/* Queue and Timer Info */}

diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 27109702b..2a050197a 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -93,7 +93,6 @@ export const useIncomingTask = (props: UseTaskProps) => { const [incomingTask, setIncomingTask] = useState(null); const [isAnswered, setIsAnswered] = useState(false); const [isEnded, setIsEnded] = useState(false); - const audioRef = useRef(null); // Ref for the audio element const isBrowser = selectedLoginOption === 'BROWSER'; const logError = (message: string, method: string) => { @@ -115,15 +114,6 @@ export const useIncomingTask = (props: UseTaskProps) => { setIncomingTask(null); }, []); - const handleTaskMedia = useCallback( - (track) => { - if (audioRef.current) { - audioRef.current.srcObject = new MediaStream([track]); - } - }, - [audioRef] - ); - const handleIncomingTask = useCallback((task: ITask) => { setIncomingTask(task); setIsAnswered(false); @@ -136,7 +126,6 @@ export const useIncomingTask = (props: UseTaskProps) => { if (incomingTask) { incomingTask.on(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); incomingTask.on(TASK_EVENTS.TASK_END, handleTaskEnded); - incomingTask.on(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); } return () => { @@ -144,10 +133,9 @@ export const useIncomingTask = (props: UseTaskProps) => { if (incomingTask) { incomingTask.off(TASK_EVENTS.TASK_ASSIGNED, handleTaskAssigned); incomingTask.off(TASK_EVENTS.TASK_END, handleTaskEnded); - incomingTask.off(TASK_EVENTS.TASK_MEDIA, handleTaskMedia); } }; - }, [cc, incomingTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded, handleTaskMedia]); + }, [cc, incomingTask, handleIncomingTask, handleTaskAssigned, handleTaskEnded]); const accept = () => { const taskId = incomingTask?.data.interactionId; @@ -189,13 +177,13 @@ export const useIncomingTask = (props: UseTaskProps) => { accept, decline, isBrowser, - audioRef, }; }; 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, { @@ -208,12 +196,23 @@ export const useCallControl = (props: useCallControlProps) => { setWrapupRequired(wrapupRequired); }, []); + const handleTaskMedia = useCallback( + (track) => { + console.log('Shreyas: Calling handleTaskMedia in call control', audioRef, audioRef.current, track, currentTask); + 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]); @@ -280,6 +279,7 @@ export const useCallControl = (props: useCallControlProps) => { return { currentTask, + audioRef, endCall, toggleHold, toggleRecording, diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 513566885..55fb1c2ad 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -85,11 +85,6 @@ export interface TaskProps { */ taskList: ITask[]; - /** - * Audio reference - */ - audioRef: React.RefObject; - /** * The logger instance from SDK */ @@ -103,7 +98,7 @@ export type UseTaskListProps = Pick< >; export type IncomingTaskPresentationalProps = Pick< TaskProps, - 'incomingTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' | 'audioRef' + 'incomingTask' | 'isBrowser' | 'isAnswered' | 'isEnded' | 'accept' | 'decline' >; export type IncomingTaskProps = Pick; export type TaskListProps = Pick; @@ -131,6 +126,10 @@ export enum TASK_EVENTS { * Interface representing the properties for control actions on a task. */ export interface ControlProps { + /** + * Audio reference + */ + audioRef: React.RefObject; /** * The current task being handled. */ @@ -196,7 +195,14 @@ export type CallControlProps = Pick; export type useCallControlProps = Pick; diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 4a548a459..ab8948979 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -114,35 +114,6 @@ describe('useIncomingTask Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); - 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(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) - ); - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - act(() => { - taskMock.on.mock.calls[2][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 errors when accepting a task', async () => { const failingTask = { ...taskMock, @@ -204,31 +175,6 @@ describe('useIncomingTask Hook', () => { method: 'useIncomingTask#decline', }); }); - - 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(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) - ); - - act(() => { - ccMock.on.mock.calls[0][1](taskMock); - }); - - act(() => { - taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_MEDIA)?.[1](mockTrack); - }); - - await waitFor(() => { - expect(mockAudioElement.current.srcObject).toEqual(new MediaStream([mockTrack])); - }); - - // Ensure no errors are logged - expect(logger.error).not.toHaveBeenCalled(); - }); }); describe('useTaskList Hook', () => { @@ -497,99 +443,6 @@ describe('useTaskList Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); }); - - describe('useIncomingTask Hook - handleTaskMedia', () => { - 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, - })); - }); - - afterEach(() => { - jest.clearAllMocks(); - logger.error.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', - logger, - selectedLoginOption: '', - }) - ); - - // 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(() => - useIncomingTask({cc: ccMock, onAccepted, onDeclined, selectedLoginOption: 'BROWSER', logger}) - ); - 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(); - }); - }); }); describe('useCallControl', () => { @@ -613,7 +466,22 @@ describe('useCallControl', () => { 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(); + }); + + afterEach(() => { jest.clearAllMocks(); + logger.error.mockRestore(); }); it('should set up and clean up event listeners on currentTask', () => { @@ -892,4 +760,141 @@ describe('useCallControl', () => { 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(); + }); }); From e6aa5b4b433d12f6e528f98275b3b3404faac8aa Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Tue, 28 Jan 2025 22:22:41 +0530 Subject: [PATCH 12/12] fix(call-control): remove-console-logs --- packages/contact-center/task/src/helper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2a050197a..a30510692 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -198,7 +198,6 @@ export const useCallControl = (props: useCallControlProps) => { const handleTaskMedia = useCallback( (track) => { - console.log('Shreyas: Calling handleTaskMedia in call control', audioRef, audioRef.current, track, currentTask); if (audioRef.current) { audioRef.current.srcObject = new MediaStream([track]); }