Skip to content

Commit

Permalink
[Bug Fix]: Start New Chat appears after post survey initialization (#982
Browse files Browse the repository at this point in the history
)

* Fixed bug: maybeExcludeDeactivateChatChannel was never being called

* Prevent messages updates to be sent to counsellor after wrapup
  • Loading branch information
GPaoloni authored Oct 19, 2022
1 parent ecfd2dd commit bc23e67
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 35 deletions.
2 changes: 1 addition & 1 deletion plugin-hrm-form/src/HrmFormPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const readConfig = () => {
};
};

let cachedConfig;
let cachedConfig: ReturnType<typeof readConfig>;

try {
cachedConfig = readConfig();
Expand Down
92 changes: 90 additions & 2 deletions plugin-hrm-form/src/___tests__/utils/setUpActions.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -10,6 +13,7 @@ const mockFlexManager = {
};

jest.mock('@twilio/flex-ui', () => ({
...(jest.requireActual('@twilio/flex-ui') as any),
Manager: {
getInstance: () => mockFlexManager,
},
Expand All @@ -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({
Expand All @@ -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 = <ITask>{
taskSid: 'THIS IS THE TASK SID!',
channelType: '',
};

afterWrapupTask(<HrmFormPlugin.SetupObject>{ 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(<HrmFormPlugin.SetupObject>{ 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(<HrmFormPlugin.SetupObject>{ 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(<HrmFormPlugin.SetupObject>{ 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(<HrmFormPlugin.SetupObject>{ featureFlags: { enable_post_survey: true } });

expect(setOrchestrationsSpy).toHaveBeenCalledTimes(2);
expect(setOrchestrationsSpy).toHaveBeenCalledWith('wrapup', expect.any(Function));
expect(setOrchestrationsSpy).toHaveBeenCalledWith('completed', expect.any(Function));
});
});
78 changes: 46 additions & 32 deletions plugin-hrm-form/src/utils/setUpActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand All @@ -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];
Expand Down Expand Up @@ -115,11 +117,9 @@ const handleTransferredTask = async (task: ITask) => {

export const getTaskLanguage = ({ helplineLanguage }) => ({ task }) => task.attributes.language || helplineLanguage;

/**
* @param {string} messageKey
* @returns {(setupObject: ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }) => 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);
Expand Down Expand Up @@ -196,10 +196,8 @@ const safeTransfer = async (transferFunction: () => Promise<any>, task: ITask):

/**
* Custom override for TransferTask action. Saves the form to share with another counseler (if possible) and then starts the transfer
* @param {ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} setupObject
* @returns {import('@twilio/flex-ui').ReplacedActionFunction}
*/
export const customTransferTask = (setupObject: SetupObject) => async (
export const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => async (
payload: ActionPayloadWithOptions,
original: ActionFunction,
) => {
Expand Down Expand Up @@ -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<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} setupObject
*/
export const wrapupTask = (setupObject: SetupObject) =>
fromActionFunction(async payload => {
Expand All @@ -259,11 +256,9 @@ export const wrapupTask = (setupObject: SetupObject) =>
await saveEndMillis(payload);
});

/**
* @param {ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} setupObject
* @returns {import('@twilio/flex-ui').ActionFunction}
*/
const decreaseChatCapacity = (setupObject: SetupObject) => async (payload: ActionPayload): Promise<void> => {
const decreaseChatCapacity = (setupObject: SetupObject): ActionFunction => async (
payload: ActionPayload,
): Promise<void> => {
const { featureFlags } = setupObject;
const { task } = payload;
if (featureFlags.enable_manual_pulling && task.taskChannelUniqueName === 'chat') await adjustChatCapacity('decrease');
Expand All @@ -277,26 +272,36 @@ const isAseloCustomChannelTask = (task: CustomITask) =>
(<string[]>Object.values(customChannelTypes)).includes(task.channelType);

/**
* @param {ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} 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();
}
};

Expand Down Expand Up @@ -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);
}
};
1 change: 1 addition & 0 deletions plugin-hrm-form/src/utils/sharedState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("../states/contacts/reducer").TaskEntry | null>}
*/
export const loadFormSharedState = async task => {
const { featureFlags, sharedStateClient, strings } = getConfig();
Expand Down

0 comments on commit bc23e67

Please sign in to comment.