Skip to content

Commit bc23e67

Browse files
authored
[Bug Fix]: Start New Chat appears after post survey initialization (#982)
* Fixed bug: maybeExcludeDeactivateChatChannel was never being called * Prevent messages updates to be sent to counsellor after wrapup
1 parent ecfd2dd commit bc23e67

File tree

4 files changed

+138
-35
lines changed

4 files changed

+138
-35
lines changed

plugin-hrm-form/src/HrmFormPlugin.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const readConfig = () => {
6868
};
6969
};
7070

71-
let cachedConfig;
71+
let cachedConfig: ReturnType<typeof readConfig>;
7272

7373
try {
7474
cachedConfig = readConfig();

plugin-hrm-form/src/___tests__/utils/setUpActions.test.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { ITask } from '@twilio/flex-ui';
1+
/* eslint-disable camelcase */
2+
import { ITask, StateHelper, TaskHelper, ChatOrchestrator } from '@twilio/flex-ui';
23

3-
import { afterCompleteTask } from '../../utils/setUpActions';
4+
import { afterCompleteTask, afterWrapupTask, setUpPostSurvey } from '../../utils/setUpActions';
45
import { REMOVE_CONTACT_STATE } from '../../states/types';
6+
import * as HrmFormPlugin from '../../HrmFormPlugin';
7+
import * as ServerlessService from '../../services/ServerlessService';
58

69
const mockFlexManager = {
710
store: {
@@ -10,6 +13,7 @@ const mockFlexManager = {
1013
};
1114

1215
jest.mock('@twilio/flex-ui', () => ({
16+
...(jest.requireActual('@twilio/flex-ui') as any),
1317
Manager: {
1418
getInstance: () => mockFlexManager,
1519
},
@@ -18,6 +22,10 @@ jest.mock('@twilio/flex-ui', () => ({
1822
jest.mock('../../HrmFormPlugin.tsx', () => ({}));
1923
jest.mock('../../states', () => ({}));
2024

25+
afterEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
2129
describe('afterCompleteTask', () => {
2230
test('Dispatches a removeContactState action with the specified taskSid', () => {
2331
afterCompleteTask({
@@ -31,3 +39,83 @@ describe('afterCompleteTask', () => {
3139
});
3240
});
3341
});
42+
43+
describe('afterWrapupTask', () => {
44+
test('featureFlags.enable_post_survey === false should not trigger post survey', async () => {
45+
const getChatChannelStateForTaskSpy = jest.spyOn(StateHelper, 'getChatChannelStateForTask');
46+
const postSurveyInitSpy = jest.spyOn(ServerlessService, 'postSurveyInit').mockImplementationOnce(async () => ({}));
47+
48+
const task = <ITask>{
49+
taskSid: 'THIS IS THE TASK SID!',
50+
channelType: '',
51+
};
52+
53+
afterWrapupTask(<HrmFormPlugin.SetupObject>{ featureFlags: { enable_post_survey: false } })({ task });
54+
55+
expect(getChatChannelStateForTaskSpy).not.toHaveBeenCalled();
56+
expect(postSurveyInitSpy).not.toHaveBeenCalled();
57+
});
58+
59+
test('featureFlags.enable_post_survey === true should not trigger post survey for non-chat task', async () => {
60+
const task = ({
61+
attributes: { channelSid: undefined },
62+
taskSid: 'THIS IS THE TASK SID!',
63+
channelType: 'voice',
64+
taskChannelUniqueName: 'voice',
65+
} as unknown) as ITask;
66+
67+
jest.spyOn(TaskHelper, 'isChatBasedTask').mockImplementation(() => false);
68+
const getChatChannelStateForTaskSpy = jest.spyOn(StateHelper, 'getChatChannelStateForTask');
69+
const postSurveyInitSpy = jest.spyOn(ServerlessService, 'postSurveyInit').mockImplementation(async () => ({}));
70+
71+
afterWrapupTask(<HrmFormPlugin.SetupObject>{ featureFlags: { enable_post_survey: true } })({ task });
72+
73+
expect(getChatChannelStateForTaskSpy).not.toHaveBeenCalled();
74+
expect(postSurveyInitSpy).not.toHaveBeenCalled();
75+
});
76+
77+
test('featureFlags.enable_post_survey === true should trigger post survey for chat task', async () => {
78+
const task = ({
79+
attributes: { channelSid: 'CHxxxxxx' },
80+
taskSid: 'THIS IS THE TASK SID!',
81+
channelType: 'web',
82+
taskChannelUniqueName: 'chat',
83+
} as unknown) as ITask;
84+
85+
jest.spyOn(TaskHelper, 'isChatBasedTask').mockImplementation(() => true);
86+
jest.spyOn(TaskHelper, 'getTaskChatChannelSid').mockImplementationOnce(() => task.attributes.channelSid);
87+
const getChatChannelStateForTaskSpy = jest.spyOn(StateHelper, 'getChatChannelStateForTask').mockImplementationOnce(
88+
() =>
89+
({
90+
source: {
91+
removeAllListeners: jest.fn(),
92+
},
93+
} as any),
94+
);
95+
const postSurveyInitSpy = jest.spyOn(ServerlessService, 'postSurveyInit').mockImplementation(async () => ({}));
96+
97+
afterWrapupTask(<HrmFormPlugin.SetupObject>{ featureFlags: { enable_post_survey: true } })({ task });
98+
99+
expect(getChatChannelStateForTaskSpy).toHaveBeenCalled();
100+
expect(postSurveyInitSpy).toHaveBeenCalled();
101+
});
102+
});
103+
104+
describe('setUpPostSurvey', () => {
105+
test('featureFlags.enable_post_survey === false should not change ChatOrchestrator', async () => {
106+
const setOrchestrationsSpy = jest.spyOn(ChatOrchestrator, 'setOrchestrations');
107+
setUpPostSurvey(<HrmFormPlugin.SetupObject>{ featureFlags: { enable_post_survey: false } });
108+
109+
expect(setOrchestrationsSpy).not.toHaveBeenCalled();
110+
});
111+
112+
test('featureFlags.enable_post_survey === true should change ChatOrchestrator', async () => {
113+
const setOrchestrationsSpy = jest.spyOn(ChatOrchestrator, 'setOrchestrations').mockImplementation();
114+
115+
setUpPostSurvey(<HrmFormPlugin.SetupObject>{ featureFlags: { enable_post_survey: true } });
116+
117+
expect(setOrchestrationsSpy).toHaveBeenCalledTimes(2);
118+
expect(setOrchestrationsSpy).toHaveBeenCalledWith('wrapup', expect.any(Function));
119+
expect(setOrchestrationsSpy).toHaveBeenCalledWith('completed', expect.any(Function));
120+
});
121+
});

plugin-hrm-form/src/utils/setUpActions.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import {
88
ChatOrchestrator,
99
ITask,
1010
ActionFunction,
11+
ReplacedActionFunction,
12+
ChatOrchestratorEvent,
13+
ChatChannelHelper,
1114
} from '@twilio/flex-ui';
1215
import { callTypes } from 'hrm-form-definitions';
16+
import type { ChatOrchestrationsEvents } from '@twilio/flex-ui/src/ChatOrchestrator';
1317

1418
import { DEFAULT_TRANSFER_MODE, getConfig } from '../HrmFormPlugin';
1519
import {
@@ -50,7 +54,6 @@ export const loadCurrentDefinitionVersion = async () => {
5054

5155
/**
5256
* Given a taskSid, retrieves the state of the form (stored in redux) for that task
53-
* @param {string} taskSid
5457
*/
5558
const getStateContactForms = (taskSid: string) => {
5659
return Manager.getInstance().store.getState()[namespace][contactFormsBase].tasks[taskSid];
@@ -77,7 +80,6 @@ const fromActionFunction = (fun: ActionFunction) => async (payload: ActionPayloa
7780

7881
/**
7982
* Initializes an empty form (in redux store) for the task within payload
80-
* @param {{ task: any }} payload
8183
*/
8284
export const initializeContactForm = (payload: ActionPayload) => {
8385
const { currentDefinitionVersion } = Manager.getInstance().store.getState()[namespace][configurationBase];
@@ -115,11 +117,9 @@ const handleTransferredTask = async (task: ITask) => {
115117

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

118-
/**
119-
* @param {string} messageKey
120-
* @returns {(setupObject: ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }) => import('@twilio/flex-ui').ActionFunction}
121-
*/
122-
const sendMessageOfKey = messageKey => setupObject => async payload => {
120+
const sendMessageOfKey = (messageKey: string) => (setupObject: SetupObject): ActionFunction => async (
121+
payload: ActionPayload,
122+
) => {
123123
const { getMessage } = setupObject;
124124
const taskLanguage = getTaskLanguage(setupObject)(payload);
125125
const message = await getMessage(messageKey)(taskLanguage);
@@ -196,10 +196,8 @@ const safeTransfer = async (transferFunction: () => Promise<any>, task: ITask):
196196

197197
/**
198198
* Custom override for TransferTask action. Saves the form to share with another counseler (if possible) and then starts the transfer
199-
* @param {ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} setupObject
200-
* @returns {import('@twilio/flex-ui').ReplacedActionFunction}
201199
*/
202-
export const customTransferTask = (setupObject: SetupObject) => async (
200+
export const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => async (
203201
payload: ActionPayloadWithOptions,
204202
original: ActionFunction,
205203
) => {
@@ -249,7 +247,6 @@ export const hangupCall = fromActionFunction(saveEndMillis);
249247

250248
/**
251249
* Override for WrapupTask action. Sends a message before leaving (if it should) and saves the end time of the conversation
252-
* @param {ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} setupObject
253250
*/
254251
export const wrapupTask = (setupObject: SetupObject) =>
255252
fromActionFunction(async payload => {
@@ -259,11 +256,9 @@ export const wrapupTask = (setupObject: SetupObject) =>
259256
await saveEndMillis(payload);
260257
});
261258

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

279274
/**
280-
* @param {ReturnType<typeof getConfig> & { translateUI: (language: string) => Promise<void>; getMessage: (messageKey: string) => (language: string) => Promise<string>; }} setupObject
275+
* This function manipulates the default chat orchetrations to allow our implementation of post surveys.
276+
* Since we rely on the same chat channel as the original contact for it, we don't want it to be "deactivated" by Flex.
277+
* Hence this function modifies the following orchestration events:
278+
* - task wrapup: removes DeactivateChatChannel
279+
* - task completed: removes DeactivateChatChannel
281280
*/
281+
const setChatOrchestrationsForPostSurvey = () => {
282+
const setExcludedDeactivateChatChannel = (event: keyof ChatOrchestrationsEvents) => {
283+
const excludeDeactivateChatChannel = (orchestrations: ChatOrchestratorEvent[]) =>
284+
orchestrations.filter(e => e !== ChatOrchestratorEvent.DeactivateChatChannel);
285+
286+
const defaultOrchestrations = ChatOrchestrator.getOrchestrations(event);
287+
288+
if (Array.isArray(defaultOrchestrations)) {
289+
ChatOrchestrator.setOrchestrations(event, task => {
290+
return isAseloCustomChannelTask(task)
291+
? defaultOrchestrations
292+
: excludeDeactivateChatChannel(defaultOrchestrations);
293+
});
294+
}
295+
};
296+
297+
setExcludedDeactivateChatChannel('wrapup');
298+
setExcludedDeactivateChatChannel('completed');
299+
};
300+
282301
export const setUpPostSurvey = (setupObject: SetupObject) => {
283302
const { featureFlags } = setupObject;
284303
if (featureFlags.enable_post_survey) {
285-
const maybeExcludeDeactivateChatChannel = event => {
286-
const defaultOrchestrations = ChatOrchestrator.getOrchestrations(event);
287-
if (Array.isArray(defaultOrchestrations)) {
288-
const excludeDeactivateChatChannel = defaultOrchestrations.filter(e => e !== 'DeactivateChatChannel');
289-
290-
ChatOrchestrator.setOrchestrations(
291-
event,
292-
// Instead than setting the orchestrations as a list of actions (ChatOrchestration[]), we can set it to a callback with type (task: ITask) => ChatOrchestration[]
293-
task => (isAseloCustomChannelTask(task) ? defaultOrchestrations : excludeDeactivateChatChannel),
294-
);
295-
}
296-
297-
maybeExcludeDeactivateChatChannel('wrapup');
298-
maybeExcludeDeactivateChatChannel('completed');
299-
};
304+
setChatOrchestrationsForPostSurvey();
300305
}
301306
};
302307

@@ -326,6 +331,15 @@ export const afterWrapupTask = (setupObject: SetupObject) => async (payload: Act
326331
const { featureFlags } = setupObject;
327332

328333
if (featureFlags.enable_post_survey) {
334+
if (TaskHelper.isChatBasedTask(payload.task)) {
335+
const channelState = StateHelper.getChatChannelStateForTask(payload.task);
336+
337+
channelState.source?.removeAllListeners('messageAdded');
338+
channelState.source?.removeAllListeners('typingStarted');
339+
channelState.source?.removeAllListeners('typingEnded');
340+
}
341+
342+
// TODO: make this occur in taskrouter callback
329343
await triggerPostSurvey(setupObject, payload);
330344
}
331345
};

plugin-hrm-form/src/utils/sharedState.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const saveFormSharedState = async (form, task) => {
4343
/**
4444
* Restores the contact form from Sync Client (if there is any)
4545
* @param {import("@twilio/flex-ui").ITask} task
46+
* @returns {Promise<import("../states/contacts/reducer").TaskEntry | null>}
4647
*/
4748
export const loadFormSharedState = async task => {
4849
const { featureFlags, sharedStateClient, strings } = getConfig();

0 commit comments

Comments
 (0)