diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index f75a8d4987..155c06e8fb 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -68,7 +68,7 @@ const readConfig = () => { }; }; -let cachedConfig; +let cachedConfig: ReturnType; try { cachedConfig = readConfig(); diff --git a/plugin-hrm-form/src/___tests__/utils/setUpActions.test.ts b/plugin-hrm-form/src/___tests__/utils/setUpActions.test.ts index 8de6f85aec..a39555fd77 100644 --- a/plugin-hrm-form/src/___tests__/utils/setUpActions.test.ts +++ b/plugin-hrm-form/src/___tests__/utils/setUpActions.test.ts @@ -1,7 +1,10 @@ -import { ITask } from '@twilio/flex-ui'; +/* eslint-disable camelcase */ +import { ITask, StateHelper, TaskHelper, ChatOrchestrator } from '@twilio/flex-ui'; -import { afterCompleteTask } from '../../utils/setUpActions'; +import { afterCompleteTask, afterWrapupTask, setUpPostSurvey } from '../../utils/setUpActions'; import { REMOVE_CONTACT_STATE } from '../../states/types'; +import * as HrmFormPlugin from '../../HrmFormPlugin'; +import * as ServerlessService from '../../services/ServerlessService'; const mockFlexManager = { store: { @@ -10,6 +13,7 @@ const mockFlexManager = { }; jest.mock('@twilio/flex-ui', () => ({ + ...(jest.requireActual('@twilio/flex-ui') as any), Manager: { getInstance: () => mockFlexManager, }, @@ -18,6 +22,10 @@ jest.mock('@twilio/flex-ui', () => ({ jest.mock('../../HrmFormPlugin.tsx', () => ({})); jest.mock('../../states', () => ({})); +afterEach(() => { + jest.clearAllMocks(); +}); + describe('afterCompleteTask', () => { test('Dispatches a removeContactState action with the specified taskSid', () => { afterCompleteTask({ @@ -31,3 +39,83 @@ describe('afterCompleteTask', () => { }); }); }); + +describe('afterWrapupTask', () => { + test('featureFlags.enable_post_survey === false should not trigger post survey', async () => { + const getChatChannelStateForTaskSpy = jest.spyOn(StateHelper, 'getChatChannelStateForTask'); + const postSurveyInitSpy = jest.spyOn(ServerlessService, 'postSurveyInit').mockImplementationOnce(async () => ({})); + + const task = { + taskSid: 'THIS IS THE TASK SID!', + channelType: '', + }; + + afterWrapupTask({ featureFlags: { enable_post_survey: false } })({ task }); + + expect(getChatChannelStateForTaskSpy).not.toHaveBeenCalled(); + expect(postSurveyInitSpy).not.toHaveBeenCalled(); + }); + + test('featureFlags.enable_post_survey === true should not trigger post survey for non-chat task', async () => { + const task = ({ + attributes: { channelSid: undefined }, + taskSid: 'THIS IS THE TASK SID!', + channelType: 'voice', + taskChannelUniqueName: 'voice', + } as unknown) as ITask; + + jest.spyOn(TaskHelper, 'isChatBasedTask').mockImplementation(() => false); + const getChatChannelStateForTaskSpy = jest.spyOn(StateHelper, 'getChatChannelStateForTask'); + const postSurveyInitSpy = jest.spyOn(ServerlessService, 'postSurveyInit').mockImplementation(async () => ({})); + + afterWrapupTask({ featureFlags: { enable_post_survey: true } })({ task }); + + expect(getChatChannelStateForTaskSpy).not.toHaveBeenCalled(); + expect(postSurveyInitSpy).not.toHaveBeenCalled(); + }); + + test('featureFlags.enable_post_survey === true should trigger post survey for chat task', async () => { + const task = ({ + attributes: { channelSid: 'CHxxxxxx' }, + taskSid: 'THIS IS THE TASK SID!', + channelType: 'web', + taskChannelUniqueName: 'chat', + } as unknown) as ITask; + + jest.spyOn(TaskHelper, 'isChatBasedTask').mockImplementation(() => true); + jest.spyOn(TaskHelper, 'getTaskChatChannelSid').mockImplementationOnce(() => task.attributes.channelSid); + const getChatChannelStateForTaskSpy = jest.spyOn(StateHelper, 'getChatChannelStateForTask').mockImplementationOnce( + () => + ({ + source: { + removeAllListeners: jest.fn(), + }, + } as any), + ); + const postSurveyInitSpy = jest.spyOn(ServerlessService, 'postSurveyInit').mockImplementation(async () => ({})); + + afterWrapupTask({ featureFlags: { enable_post_survey: true } })({ task }); + + expect(getChatChannelStateForTaskSpy).toHaveBeenCalled(); + expect(postSurveyInitSpy).toHaveBeenCalled(); + }); +}); + +describe('setUpPostSurvey', () => { + test('featureFlags.enable_post_survey === false should not change ChatOrchestrator', async () => { + const setOrchestrationsSpy = jest.spyOn(ChatOrchestrator, 'setOrchestrations'); + setUpPostSurvey({ featureFlags: { enable_post_survey: false } }); + + expect(setOrchestrationsSpy).not.toHaveBeenCalled(); + }); + + test('featureFlags.enable_post_survey === true should change ChatOrchestrator', async () => { + const setOrchestrationsSpy = jest.spyOn(ChatOrchestrator, 'setOrchestrations').mockImplementation(); + + setUpPostSurvey({ featureFlags: { enable_post_survey: true } }); + + expect(setOrchestrationsSpy).toHaveBeenCalledTimes(2); + expect(setOrchestrationsSpy).toHaveBeenCalledWith('wrapup', expect.any(Function)); + expect(setOrchestrationsSpy).toHaveBeenCalledWith('completed', expect.any(Function)); + }); +}); diff --git a/plugin-hrm-form/src/utils/setUpActions.ts b/plugin-hrm-form/src/utils/setUpActions.ts index 41b6619530..70820a4a0b 100644 --- a/plugin-hrm-form/src/utils/setUpActions.ts +++ b/plugin-hrm-form/src/utils/setUpActions.ts @@ -8,8 +8,12 @@ import { ChatOrchestrator, ITask, ActionFunction, + ReplacedActionFunction, + ChatOrchestratorEvent, + ChatChannelHelper, } from '@twilio/flex-ui'; import { callTypes } from 'hrm-form-definitions'; +import type { ChatOrchestrationsEvents } from '@twilio/flex-ui/src/ChatOrchestrator'; import { DEFAULT_TRANSFER_MODE, getConfig } from '../HrmFormPlugin'; import { @@ -50,7 +54,6 @@ export const loadCurrentDefinitionVersion = async () => { /** * Given a taskSid, retrieves the state of the form (stored in redux) for that task - * @param {string} taskSid */ const getStateContactForms = (taskSid: string) => { return Manager.getInstance().store.getState()[namespace][contactFormsBase].tasks[taskSid]; @@ -77,7 +80,6 @@ const fromActionFunction = (fun: ActionFunction) => async (payload: ActionPayloa /** * Initializes an empty form (in redux store) for the task within payload - * @param {{ task: any }} payload */ export const initializeContactForm = (payload: ActionPayload) => { const { currentDefinitionVersion } = Manager.getInstance().store.getState()[namespace][configurationBase]; @@ -115,11 +117,9 @@ const handleTransferredTask = async (task: ITask) => { export const getTaskLanguage = ({ helplineLanguage }) => ({ task }) => task.attributes.language || helplineLanguage; -/** - * @param {string} messageKey - * @returns {(setupObject: ReturnType & { translateUI: (language: string) => Promise; getMessage: (messageKey: string) => (language: string) => Promise; }) => import('@twilio/flex-ui').ActionFunction} - */ -const sendMessageOfKey = messageKey => setupObject => async payload => { +const sendMessageOfKey = (messageKey: string) => (setupObject: SetupObject): ActionFunction => async ( + payload: ActionPayload, +) => { const { getMessage } = setupObject; const taskLanguage = getTaskLanguage(setupObject)(payload); const message = await getMessage(messageKey)(taskLanguage); @@ -196,10 +196,8 @@ const safeTransfer = async (transferFunction: () => Promise, task: ITask): /** * Custom override for TransferTask action. Saves the form to share with another counseler (if possible) and then starts the transfer - * @param {ReturnType & { translateUI: (language: string) => Promise; getMessage: (messageKey: string) => (language: string) => Promise; }} setupObject - * @returns {import('@twilio/flex-ui').ReplacedActionFunction} */ -export const customTransferTask = (setupObject: SetupObject) => async ( +export const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => async ( payload: ActionPayloadWithOptions, original: ActionFunction, ) => { @@ -249,7 +247,6 @@ export const hangupCall = fromActionFunction(saveEndMillis); /** * Override for WrapupTask action. Sends a message before leaving (if it should) and saves the end time of the conversation - * @param {ReturnType & { translateUI: (language: string) => Promise; getMessage: (messageKey: string) => (language: string) => Promise; }} setupObject */ export const wrapupTask = (setupObject: SetupObject) => fromActionFunction(async payload => { @@ -259,11 +256,9 @@ export const wrapupTask = (setupObject: SetupObject) => await saveEndMillis(payload); }); -/** - * @param {ReturnType & { translateUI: (language: string) => Promise; getMessage: (messageKey: string) => (language: string) => Promise; }} setupObject - * @returns {import('@twilio/flex-ui').ActionFunction} - */ -const decreaseChatCapacity = (setupObject: SetupObject) => async (payload: ActionPayload): Promise => { +const decreaseChatCapacity = (setupObject: SetupObject): ActionFunction => async ( + payload: ActionPayload, +): Promise => { const { featureFlags } = setupObject; const { task } = payload; if (featureFlags.enable_manual_pulling && task.taskChannelUniqueName === 'chat') await adjustChatCapacity('decrease'); @@ -277,26 +272,36 @@ const isAseloCustomChannelTask = (task: CustomITask) => (Object.values(customChannelTypes)).includes(task.channelType); /** - * @param {ReturnType & { translateUI: (language: string) => Promise; getMessage: (messageKey: string) => (language: string) => Promise; }} setupObject + * This function manipulates the default chat orchetrations to allow our implementation of post surveys. + * Since we rely on the same chat channel as the original contact for it, we don't want it to be "deactivated" by Flex. + * Hence this function modifies the following orchestration events: + * - task wrapup: removes DeactivateChatChannel + * - task completed: removes DeactivateChatChannel */ +const setChatOrchestrationsForPostSurvey = () => { + const setExcludedDeactivateChatChannel = (event: keyof ChatOrchestrationsEvents) => { + const excludeDeactivateChatChannel = (orchestrations: ChatOrchestratorEvent[]) => + orchestrations.filter(e => e !== ChatOrchestratorEvent.DeactivateChatChannel); + + const defaultOrchestrations = ChatOrchestrator.getOrchestrations(event); + + if (Array.isArray(defaultOrchestrations)) { + ChatOrchestrator.setOrchestrations(event, task => { + return isAseloCustomChannelTask(task) + ? defaultOrchestrations + : excludeDeactivateChatChannel(defaultOrchestrations); + }); + } + }; + + setExcludedDeactivateChatChannel('wrapup'); + setExcludedDeactivateChatChannel('completed'); +}; + export const setUpPostSurvey = (setupObject: SetupObject) => { const { featureFlags } = setupObject; if (featureFlags.enable_post_survey) { - const maybeExcludeDeactivateChatChannel = event => { - const defaultOrchestrations = ChatOrchestrator.getOrchestrations(event); - if (Array.isArray(defaultOrchestrations)) { - const excludeDeactivateChatChannel = defaultOrchestrations.filter(e => e !== 'DeactivateChatChannel'); - - ChatOrchestrator.setOrchestrations( - event, - // Instead than setting the orchestrations as a list of actions (ChatOrchestration[]), we can set it to a callback with type (task: ITask) => ChatOrchestration[] - task => (isAseloCustomChannelTask(task) ? defaultOrchestrations : excludeDeactivateChatChannel), - ); - } - - maybeExcludeDeactivateChatChannel('wrapup'); - maybeExcludeDeactivateChatChannel('completed'); - }; + setChatOrchestrationsForPostSurvey(); } }; @@ -326,6 +331,15 @@ export const afterWrapupTask = (setupObject: SetupObject) => async (payload: Act const { featureFlags } = setupObject; if (featureFlags.enable_post_survey) { + if (TaskHelper.isChatBasedTask(payload.task)) { + const channelState = StateHelper.getChatChannelStateForTask(payload.task); + + channelState.source?.removeAllListeners('messageAdded'); + channelState.source?.removeAllListeners('typingStarted'); + channelState.source?.removeAllListeners('typingEnded'); + } + + // TODO: make this occur in taskrouter callback await triggerPostSurvey(setupObject, payload); } }; diff --git a/plugin-hrm-form/src/utils/sharedState.js b/plugin-hrm-form/src/utils/sharedState.js index 3fc3705fed..d2389f4b31 100644 --- a/plugin-hrm-form/src/utils/sharedState.js +++ b/plugin-hrm-form/src/utils/sharedState.js @@ -43,6 +43,7 @@ export const saveFormSharedState = async (form, task) => { /** * Restores the contact form from Sync Client (if there is any) * @param {import("@twilio/flex-ui").ITask} task + * @returns {Promise} */ export const loadFormSharedState = async task => { const { featureFlags, sharedStateClient, strings } = getConfig();