From 6c1246ceabaa88afc4a7b039963858023daa76d5 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 19 Dec 2023 11:32:38 +0000 Subject: [PATCH] Removed twilio sync based transfers & associated legacy types. Reorganised transfer code slightly, updated tests --- ...Button.test.js => TransferButton.test.tsx} | 11 +- .../transfer/formDataTransfer.test.ts | 297 ++++++++++++++++++ .../transferTaskState.test.ts} | 12 +- .../src/___tests__/utils/sharedState.test.ts | 290 ----------------- .../src/channels/setUpChannels.tsx | 4 +- .../Conference/ConferenceMonitor/index.tsx | 2 +- plugin-hrm-form/src/components/TaskView.tsx | 4 +- .../callTypeButtons/CallTypeButtons.tsx | 2 +- .../src/components/search/ConnectDialog.tsx | 4 +- .../components/search/SearchResults/index.tsx | 28 +- .../src/components/tabbedForms/BottomBar.tsx | 2 +- .../components/tabbedForms/TabbedForms.tsx | 20 +- .../transfer/AcceptTransferButton.tsx | 4 +- .../transfer/RejectTransferButton.tsx | 4 +- .../components/transfer/TransferButton.tsx | 4 +- .../src/conference/setUpConferenceActions.tsx | 4 +- plugin-hrm-form/src/states/storeNamespaces.ts | 2 +- .../src/transfer/formDataTransfer.ts | 70 +++++ .../src/transfer/setUpTransferActions.tsx | 11 +- .../transferTaskState.ts} | 8 +- plugin-hrm-form/src/utils/setUpActions.ts | 4 +- plugin-hrm-form/src/utils/setUpComponents.tsx | 4 +- .../src/utils/setUpTaskRouterListeners.ts | 4 +- plugin-hrm-form/src/utils/sharedState.ts | 200 +----------- .../src/utils/shouldSendInsightsData.ts | 2 +- 25 files changed, 429 insertions(+), 568 deletions(-) rename plugin-hrm-form/src/___tests__/components/transfer/{TransferButton.test.js => TransferButton.test.tsx} (87%) create mode 100644 plugin-hrm-form/src/___tests__/transfer/formDataTransfer.test.ts rename plugin-hrm-form/src/___tests__/{utils/transfer.test.ts => transfer/transferTaskState.test.ts} (97%) delete mode 100644 plugin-hrm-form/src/___tests__/utils/sharedState.test.ts create mode 100644 plugin-hrm-form/src/transfer/formDataTransfer.ts rename plugin-hrm-form/src/{utils/transfer.ts => transfer/transferTaskState.ts} (95%) diff --git a/plugin-hrm-form/src/___tests__/components/transfer/TransferButton.test.js b/plugin-hrm-form/src/___tests__/components/transfer/TransferButton.test.tsx similarity index 87% rename from plugin-hrm-form/src/___tests__/components/transfer/TransferButton.test.js rename to plugin-hrm-form/src/___tests__/components/transfer/TransferButton.test.tsx index 614f7ff10c..2e45650920 100644 --- a/plugin-hrm-form/src/___tests__/components/transfer/TransferButton.test.js +++ b/plugin-hrm-form/src/___tests__/components/transfer/TransferButton.test.tsx @@ -14,16 +14,15 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React from 'react'; +import * as React from 'react'; import { configureAxe, toHaveNoViolations } from 'jest-axe'; import { mount } from 'enzyme'; import { StorelessThemeProvider, withTheme } from '@twilio/flex-ui'; -import HrmTheme from '../../../styles/HrmTheme'; import TransferButton from '../../../components/transfer/TransferButton'; import { transferStatuses } from '../../../states/DomainConstants'; -jest.mock('../../../utils/transfer', () => ({ +jest.mock('../../../transfer/transferTaskState', () => ({ canTransferConference: () => true, })); @@ -38,12 +37,8 @@ const task = { const Wrapped = withTheme(props => ); test('a11y', async () => { - const themeConf = { - colorTheme: HrmTheme, - }; - const wrapper = mount( - + , ); diff --git a/plugin-hrm-form/src/___tests__/transfer/formDataTransfer.test.ts b/plugin-hrm-form/src/___tests__/transfer/formDataTransfer.test.ts new file mode 100644 index 0000000000..500b9ce158 --- /dev/null +++ b/plugin-hrm-form/src/___tests__/transfer/formDataTransfer.test.ts @@ -0,0 +1,297 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable global-require */ +/* eslint-disable camelcase */ +import { DefinitionVersionId, loadDefinition, useFetchDefinitions } from 'hrm-form-definitions'; +import { Manager } from '@twilio/flex-ui'; + +import { baseMockConfig, mockGetDefinitionsResponse } from '../mockGetConfig'; +import { createTask } from '../helpers'; +import { getDefinitionVersions } from '../../hrmConfig'; +import { ContactState } from '../../states/contacts/existingContacts'; +import { Case, Contact } from '../../types/types'; +import { ContactMetadata } from '../../states/contacts/types'; +import { VALID_EMPTY_CONTACT } from '../testContacts'; +import { connectToCaseAsyncAction, updateContactInHrmAsyncAction } from '../../states/contacts/saveContact'; +import { loadFormSharedState, saveFormSharedState } from '../../transfer/formDataTransfer'; +import { getUnsavedContact } from '../../states/contacts/getUnsavedContact'; + +jest.mock('../../states/contacts/saveContact', () => ({ + ...jest.requireActual('../../states/contacts/saveContact'), + updateContactInHrmAsyncAction: jest.fn((id, contact, _, task) => { + return Promise.resolve(); + }), + connectToCaseAsyncAction: jest.fn((id, caseId) => { + return Promise.resolve(); + }), +})); + +jest.mock('@twilio/flex-ui', () => ({ + ...(jest.requireActual('@twilio/flex-ui') as any), + Manager: { + getInstance: jest.fn(), + }, +})); + +jest.mock('../../fullStory', () => ({ + recordBackendError: jest.fn(), +})); + +let transferContactState; + +let mockFlexManager; + +const contact = { helpline: 'a helpline' } as Contact; +const metadata = {} as ContactMetadata; +const contactState: ContactState = { + savedContact: contact, + metadata, + references: new Set(), +}; +const task = createTask(); + +let mockV1; + +const mockUpdateContactInHrmAsyncAction = updateContactInHrmAsyncAction as jest.MockedFunction< + typeof updateContactInHrmAsyncAction +>; + +const mockConnectToCaseAsyncAction = connectToCaseAsyncAction as jest.MockedFunction; + +// eslint-disable-next-line react-hooks/rules-of-hooks +const { mockFetchImplementation, buildBaseURL } = useFetchDefinitions(); + +const mockGetState = jest.fn(); + +beforeAll(async () => { + const formDefinitionsBaseUrl = buildBaseURL(DefinitionVersionId.v1); + await mockFetchImplementation(formDefinitionsBaseUrl); + mockV1 = await loadDefinition(formDefinitionsBaseUrl); + mockGetDefinitionsResponse(getDefinitionVersions, DefinitionVersionId.v1, mockV1); +}); + +beforeEach(async () => { + jest.clearAllMocks(); + transferContactState = { + savedContact: { + ...VALID_EMPTY_CONTACT, + rawJson: { + categories: undefined, + contactlessTask: undefined, + draft: undefined, + } as any, + timeOfContact: new Date().toISOString(), + csamReports: undefined, + referrals: undefined, + helpline: 'a helpline', + channel: 'web', + taskId: 'transferred-task-id', + }, + metadata: { + draft: undefined, + } as ContactMetadata, + references: new Set(['task-transferred-task-id']), + }; + + mockUpdateContactInHrmAsyncAction.mockImplementation((previousContact, changes, reference) => ({ + type: 'contact-action/update-contact', + payload: Promise.resolve({ previousContact, contact: getUnsavedContact(previousContact, changes), reference }), + meta: { previousContact, changes }, + })); + + mockConnectToCaseAsyncAction.mockImplementation((contactId, caseId) => ({ + type: 'contact-action/connect-to-case', + payload: Promise.resolve({ + contactId, + caseId, + contact: { id: contactId, caseId } as Contact, + contactCase: { id: caseId } as Case, + }), + meta: {}, + })); + + mockGetState.mockReturnValue({ + 'plugin-hrm-form': { + activeContacts: { + existingContacts: { + 12345: transferContactState as ContactState, + }, + }, + }, + }); + + mockFlexManager = { + store: { + getState: mockGetState, + dispatch: jest.fn(), + }, + }; + (Manager.getInstance as jest.Mock).mockReturnValue(mockFlexManager); + await task.setAttributes({ + transferMeta: { + originalTask: 'transferred-task-id', + }, + }); + baseMockConfig.featureFlags.enable_transfers = true; +}); + +describe('saveFormSharedState', () => { + test('flag disabled - does nothing', async () => { + baseMockConfig.featureFlags.enable_transfers = false; + + await saveFormSharedState( + { + ...contactState, + savedContact: { ...contactState.savedContact, caseId: '1234' }, + draftContact: { channel: 'whatsapp' }, + }, + task, + ); + expect(updateContactInHrmAsyncAction).not.toHaveBeenCalled(); + expect(connectToCaseAsyncAction).not.toHaveBeenCalled(); + }); + + test('Has draft changes - should save them', async () => { + await saveFormSharedState({ ...contactState, draftContact: { channel: 'whatsapp' } }, task); + expect(updateContactInHrmAsyncAction).toHaveBeenCalledWith( + contactState.savedContact, + { channel: 'whatsapp' }, + 'task-taskSid', + ); + expect(connectToCaseAsyncAction).not.toHaveBeenCalled(); + }); + + test('Has case ID set - should disconnect it', async () => { + await saveFormSharedState( + { ...contactState, savedContact: { ...contactState.savedContact, caseId: '1234' } }, + task, + ); + expect(updateContactInHrmAsyncAction).not.toHaveBeenCalled(); + expect(connectToCaseAsyncAction).toHaveBeenCalledWith(contactState.savedContact.id, undefined); + }); + + test('Has case ID set and draft changes - should disconnect the case and save the changes', async () => { + await saveFormSharedState( + { + ...contactState, + savedContact: { ...contactState.savedContact, caseId: '1234' }, + draftContact: { channel: 'whatsapp' }, + }, + task, + ); + expect(updateContactInHrmAsyncAction).toHaveBeenCalledWith( + { ...contactState.savedContact, caseId: '1234' }, + { channel: 'whatsapp' }, + 'task-taskSid', + ); + expect(connectToCaseAsyncAction).toHaveBeenCalledWith(contactState.savedContact.id, undefined); + }); + + test('Has no case ID set and draft changes - should disconnect the case and save the changes', async () => { + await saveFormSharedState(contactState, task); + expect(updateContactInHrmAsyncAction).not.toHaveBeenCalled(); + expect(connectToCaseAsyncAction).not.toHaveBeenCalled(); + }); + + test('HRM update endpoint errors - still disconnects case', async () => { + const changes = { channel: 'whatsapp' } as const; + const contact = { ...contactState.savedContact, caseId: '1234' }; + const updateAction = { + type: 'contact-action/update-contact', + payload: Promise.reject(new Error('update error')), + meta: { previousContact: contact, changes }, + } as const; + mockUpdateContactInHrmAsyncAction.mockReturnValue(updateAction); + await saveFormSharedState({ ...contactState, savedContact: contact, draftContact: changes }, task); + expect(connectToCaseAsyncAction).toHaveBeenCalledWith(contactState.savedContact.id, undefined); + // Bit weird to assert a mocked value here, but it confirms the rejection case we are testing actually occurs + // It also prevents an unhandled error bubbling up to the top level of the test suite and failing it + await expect(updateAction.payload).rejects.toEqual(new Error('update error')); + }); + + test('HRM disconnect endpoint errors - still updates contact', async () => { + const changes = { channel: 'whatsapp' } as const; + const contact = { ...contactState.savedContact, caseId: '1234' }; + const connectAction = { + type: 'contact-action/connect-to-case', + payload: Promise.reject(new Error('disconnect error')), + meta: {}, + } as const; + mockConnectToCaseAsyncAction.mockReturnValue(connectAction); + await saveFormSharedState({ ...contactState, savedContact: contact, draftContact: changes }, task); + expect(connectToCaseAsyncAction).toHaveBeenCalledWith(contactState.savedContact.id, undefined); + // Bit weird to assert a mocked value here, but it confirms the rejection case we are testing actually occurs + // It also prevents an unhandled error bubbling up to the top level of the test suite and failing it + await expect(connectAction.payload).rejects.toEqual(new Error('disconnect error')); + }); +}); +describe('loadFormSharedState', () => { + let expected: ContactState; + + beforeEach(async () => { + expected = { + ...transferContactState, + savedContact: { + ...transferContactState.savedContact, + timeOfContact: expect.any(String), + taskId: 'taskSid', + } as Contact, + }; + await task.setAttributes({ + transferMeta: { + originalTask: 'transferred-task-id', + }, + }); + }); + + test('Flag disabled - does nothing', async () => { + baseMockConfig.featureFlags.enable_transfers = false; + const loadedForm = await loadFormSharedState(task); + expect(loadedForm).toBeNull(); + expect(updateContactInHrmAsyncAction).not.toHaveBeenCalled(); + }); + + test('Should save the contact back to HRM with the tasks current task SID', async () => { + const loadedForm = await loadFormSharedState(task); + expect(loadedForm).toStrictEqual(expected); + expect(updateContactInHrmAsyncAction).toHaveBeenCalledWith( + expected.savedContact, + expected.savedContact, + 'task-taskSid', + ); + }); + + test('HRM update endpoint errors - does nothing & returns original state', async () => { + const updateAction = { + type: 'contact-action/update-contact', + payload: Promise.reject(new Error('update error')), + meta: { previousContact: expected.savedContact, changes: expected.savedContact }, + } as const; + + mockUpdateContactInHrmAsyncAction.mockReturnValue(updateAction); + const loadedForm = await loadFormSharedState(task); + expect(loadedForm).toEqual(transferContactState); + + expect(updateContactInHrmAsyncAction).toHaveBeenCalledWith( + expected.savedContact, + expected.savedContact, + 'task-taskSid', + ); + await expect(updateAction.payload).rejects.toEqual(new Error('update error')); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/utils/transfer.test.ts b/plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts similarity index 97% rename from plugin-hrm-form/src/___tests__/utils/transfer.test.ts rename to plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts index 7b82b65231..5bed9dfcce 100644 --- a/plugin-hrm-form/src/___tests__/utils/transfer.test.ts +++ b/plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts @@ -20,7 +20,7 @@ import { omit } from 'lodash'; import '../mockGetConfig'; import each from 'jest-each'; -import * as TransferHelpers from '../../utils/transfer'; +import * as TransferHelpers from '../../transfer/transferTaskState'; import { transferModes, transferStatuses } from '../../states/DomainConstants'; import { acceptTask, createTask } from '../helpers'; import * as callStatus from '../../states/conferencing/callStatus'; @@ -354,13 +354,12 @@ describe('Kick, close and helpers', () => { originalCounselorName: counselorName, originalConversationSid: 'channel1', transferStatus: transferStatuses.accepted, - formDocument: 'some string', mode: transferModes.cold, sidWithTaskControl: 'WR00000000000000000000000000000000', targetType: 'worker', }; - await TransferHelpers.setTransferMeta(coldPayload, 'some string', counselorName); + await TransferHelpers.setTransferMeta(coldPayload, counselorName); expect(coldTask.attributes.transferMeta).toStrictEqual(coldExpected); const warmTask = createTask( @@ -375,7 +374,6 @@ describe('Kick, close and helpers', () => { originalCounselorName: counselorName, originalConversationSid: undefined, transferStatus: transferStatuses.transferring, - formDocument: 'some string', mode: transferModes.warm, sidWithTaskControl: '', targetType: 'worker', @@ -387,7 +385,7 @@ describe('Kick, close and helpers', () => { task: warmTask, }; - await TransferHelpers.setTransferMeta(warmPayload, 'some string', counselorName); + await TransferHelpers.setTransferMeta(warmPayload, counselorName); expect(warmTask.attributes.transferMeta).toStrictEqual(warmExpected); }); @@ -405,7 +403,7 @@ describe('Kick, close and helpers', () => { task: anotherTask, }; - await TransferHelpers.setTransferMeta(coldPayload, 'some string', counselorName); + await TransferHelpers.setTransferMeta(coldPayload, counselorName); expect(anotherTask.attributes.transferMeta).not.toBeUndefined(); expect(anotherTask.attributes.transferStarted).toBeTruthy(); @@ -419,7 +417,7 @@ describe('Kick, close and helpers', () => { task: anotherTask, }; - await TransferHelpers.setTransferMeta(warmPayload, 'some string', counselorName); + await TransferHelpers.setTransferMeta(warmPayload, counselorName); expect(anotherTask.attributes.transferMeta).not.toBeUndefined(); expect(anotherTask.attributes.transferStarted).toBeTruthy(); diff --git a/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts b/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts deleted file mode 100644 index a0f060d1a2..0000000000 --- a/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Copyright (C) 2021-2023 Technology Matters - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -/* eslint-disable sonarjs/no-identical-functions */ -/* eslint-disable global-require */ -/* eslint-disable camelcase */ -import SyncClient from 'twilio-sync'; -import { DefinitionVersionId, loadDefinition, useFetchDefinitions } from 'hrm-form-definitions'; -import { Manager } from '@twilio/flex-ui'; - -import { baseMockConfig, mockGetDefinitionsResponse } from '../mockGetConfig'; -import { transferStatuses } from '../../states/DomainConstants'; -import { createTask } from '../helpers'; -import { getDefinitionVersions } from '../../hrmConfig'; -import { ContactState } from '../../states/contacts/existingContacts'; -import { Contact } from '../../types/types'; -import { ContactMetadata } from '../../states/contacts/types'; -import { VALID_EMPTY_CONTACT } from '../testContacts'; -import { RootState } from '../../states'; -import { RecursivePartial } from '../RecursivePartial'; -import { loadFormSharedState, saveFormSharedState, setUpSharedStateClient } from '../../utils/sharedState'; -import { updateContactInHrmAsyncAction } from '../../states/contacts/saveContact'; - -jest.mock('../../states/contacts/saveContact', () => ({ - ...jest.requireActual('../../states/contacts/saveContact'), - updateContactInHrmAsyncAction: jest.fn((id, contact, _, task) => { - console.log('updateContactInHrmAsyncAction', id, contact, task); - return Promise.resolve(); - }), -})); - -jest.mock('@twilio/flex-ui', () => ({ - ...(jest.requireActual('@twilio/flex-ui') as any), - Manager: { - getInstance: jest.fn(), - }, -})); - -jest.mock('../../services/ServerlessService', () => ({ - issueSyncToken: jest.fn(), -})); -jest.mock('../../fullStory', () => ({ - recordBackendError: jest.fn(), -})); - -jest.mock('twilio-sync', () => jest.fn()); -let transferContactState; - -let mockFlexManager; - -const contact = { helpline: 'a helpline' } as Contact; -const metadata = {} as ContactMetadata; -const form: ContactState = { - savedContact: contact, - metadata, - references: new Set(), -}; -const task = createTask(); - -let mockSharedStateDocuments; - -const mockSharedState = { - connectionState: 'connected', - document: async documentName => { - console.log('mockSharedState.document', documentName); - if (!mockSharedStateDocuments[documentName]) { - console.log('mockSharedState.document.create', documentName); - return { - set: async data => { - console.log('mockSharedState.document.set', documentName, data); - mockSharedStateDocuments[documentName] = { data }; - return data; - }, - }; - } - console.log('mockSharedState.document.get', documentName, mockSharedStateDocuments[documentName]); - return mockSharedStateDocuments[documentName]; - }, -}; - -let mockSyncClient: jest.Mock = (SyncClient as unknown) as jest.Mock; -let mockV1; - -// eslint-disable-next-line react-hooks/rules-of-hooks -const { mockFetchImplementation, buildBaseURL } = useFetchDefinitions(); - -beforeAll(async () => { - const formDefinitionsBaseUrl = buildBaseURL(DefinitionVersionId.v1); - await mockFetchImplementation(formDefinitionsBaseUrl); - mockV1 = await loadDefinition(formDefinitionsBaseUrl); - mockSyncClient = (SyncClient as unknown) as jest.Mock; - mockGetDefinitionsResponse(getDefinitionVersions, DefinitionVersionId.v1, mockV1); -}); - -beforeEach(async () => { - jest.clearAllMocks(); - mockSharedStateDocuments = {}; - transferContactState = { - savedContact: { - ...VALID_EMPTY_CONTACT, - rawJson: { - categories: undefined, - contactlessTask: undefined, - draft: undefined, - } as any, - timeOfContact: new Date().toISOString(), - csamReports: undefined, - referrals: undefined, - helpline: 'a helpline', - channel: 'web', - taskId: 'transferred-task-id', - }, - metadata: { - draft: undefined, - } as ContactMetadata, - references: new Set(), - }; - - mockFlexManager = { - store: { - getState: (): RecursivePartial => ({ - 'plugin-hrm-form': { - activeContacts: { - existingContacts: { - 12345: transferContactState as ContactState, - }, - }, - }, - }), - dispatch: jest.fn(), - }, - }; - (Manager.getInstance as jest.Mock).mockReturnValue(mockFlexManager); - await task.setAttributes({ - transferMeta: { - originalTask: 'transferred-task-id', - }, - }); -}); -describe('sharedState', () => { - describe('Test with no feature flag', () => { - test('saveFormSharedState', async () => { - baseMockConfig.featureFlags.enable_transfers = false; - const { saveFormSharedState } = require('../../utils/sharedState'); - - const documentName = await saveFormSharedState(form, task); - expect(documentName).toBeNull(); - }); - - test('loadFormSharedState', async () => { - baseMockConfig.featureFlags.enable_transfers = false; - const { loadFormSharedState } = require('../../utils/sharedState'); - - const loadedForm = await loadFormSharedState(task); - expect(loadedForm).toBeNull(); - }); - }); - - describe('Test with undefined sharedState', () => { - test('saveFormSharedState', async () => { - baseMockConfig.featureFlags.enable_transfers = true; - const documentName = await saveFormSharedState(form, task); - expect(documentName).toBeNull(); - }); - - test('loadFormSharedState', async () => { - baseMockConfig.featureFlags.enable_transfers = true; - - const loadedForm = await loadFormSharedState(task); - expect(loadedForm).toBe(transferContactState); - }); - }); - - describe('Test with not connected sharedState', () => { - beforeEach(async () => { - baseMockConfig.featureFlags.enable_transfers = true; - mockSyncClient.mockImplementation(() => ({ - on: jest.fn(), - connectionState: 'not connected', - })); - await setUpSharedStateClient(); - }); - - test('saveFormSharedState', async () => { - const documentName = await saveFormSharedState(form, task); - expect(documentName).toBeNull(); - }); - - test('loadFormSharedState', async () => { - const loadedForm = await loadFormSharedState(task); - expect(loadedForm).toBe(transferContactState); - }); - }); - - describe('Test with connected sharedState', () => { - beforeEach(async () => { - baseMockConfig.featureFlags.enable_transfers = true; - mockSyncClient.mockImplementation(() => mockSharedState); - await setUpSharedStateClient(); - }); - - test('saveFormSharedState', async () => { - const expected = { - helpline: 'a helpline', - categories: undefined, - csamReports: undefined, - draft: undefined, - metadata: {}, - referrals: undefined, - }; - const documentName = await saveFormSharedState(form, task); - expect(documentName).toBe('pending-form-taskSid'); - expect(mockSharedStateDocuments[documentName].data).toStrictEqual(expected); - await task.setAttributes({ - transferMeta: { transferStatus: transferStatuses.accepted, formDocument: documentName }, - }); - }); - - test('loadFormSharedState', async () => { - const expected: ContactState = { - ...transferContactState, - savedContact: { - ...transferContactState.savedContact, - timeOfContact: expect.any(String), - taskId: 'taskSid', - } as Contact, - }; - await task.setAttributes({ - transferMeta: { - originalTask: 'transferred-task-id', - formDocument: 'pending-form-transferred-task-id', - }, - }); - mockSharedStateDocuments['pending-form-transferred-task-id'] = { - data: { - categories: undefined, - contactlessTask: undefined, - draft: undefined, - helpline: 'a helpline', - }, - }; - const loadedForm = await loadFormSharedState(task); - expect(loadedForm).toStrictEqual(expected); - }); - }); - - describe('Test throwing errors', () => { - const error = jest.fn(); - console.error = error; - - beforeEach(async () => { - baseMockConfig.featureFlags.enable_transfers = true; - mockSyncClient.mockImplementation(() => ({ - document: () => { - throw new Error(); - }, - on: jest.fn(), - })); - await setUpSharedStateClient(); - error.mockClear(); - }); - - test('saveFormSharedState', async () => { - expect(error).not.toBeCalled(); - const documentName = await saveFormSharedState(form, task); - expect(documentName).toBeNull(); - expect(error).toBeCalled(); - }); - - test('loadFormSharedState', async () => { - expect(error).not.toBeCalled(); - const loadedForm = await loadFormSharedState(task); - expect(loadedForm).toBe(transferContactState); - expect(error).toBeCalled(); - }); - }); -}); diff --git a/plugin-hrm-form/src/channels/setUpChannels.tsx b/plugin-hrm-form/src/channels/setUpChannels.tsx index 8a63424699..9fdfc3effd 100644 --- a/plugin-hrm-form/src/channels/setUpChannels.tsx +++ b/plugin-hrm-form/src/channels/setUpChannels.tsx @@ -14,9 +14,9 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +import * as Flex from '@twilio/flex-ui'; import { ReservationStatuses } from '@twilio/flex-ui'; import React from 'react'; -import * as Flex from '@twilio/flex-ui'; import TwitterIcon from '../components/common/icons/TwitterIcon'; import InstagramIcon from '../components/common/icons/InstagramIcon'; @@ -25,7 +25,7 @@ import WhatsappIcon from '../components/common/icons/WhatsappIcon'; import FacebookIcon from '../components/common/icons/FacebookIcon'; import CallIcon from '../components/common/icons/CallIcon'; import SmsIcon from '../components/common/icons/SmsIcon'; -import * as TransferHelpers from '../utils/transfer'; +import * as TransferHelpers from '../transfer/transferTaskState'; import { colors, mainChannelColor } from './colors'; import { getTemplateStrings } from '../hrmConfig'; diff --git a/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx b/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx index 8b27c0e062..dfbd69cfbe 100644 --- a/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx +++ b/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { ConferenceParticipant, TaskContextProps } from '@twilio/flex-ui'; import { conferenceApi } from '../../../services/ServerlessService'; -import { hasTaskControl, isOriginalReservation, isTransferring } from '../../../utils/transfer'; +import { hasTaskControl, isOriginalReservation, isTransferring } from '../../../transfer/transferTaskState'; const isJoinedWithEnd = (p: ConferenceParticipant) => p.status === 'joined' && p.mediaProperties.endConferenceOnExit; const isJoinedWithoutEnd = (p: ConferenceParticipant) => diff --git a/plugin-hrm-form/src/components/TaskView.tsx b/plugin-hrm-form/src/components/TaskView.tsx index 501e41aeab..ec1e0e5cd6 100644 --- a/plugin-hrm-form/src/components/TaskView.tsx +++ b/plugin-hrm-form/src/components/TaskView.tsx @@ -23,8 +23,8 @@ import { DefinitionVersion } from 'hrm-form-definitions'; import HrmForm from './HrmForm'; import FormNotEditable from './FormNotEditable'; import { RootState } from '../states'; -import { hasTaskControl } from '../utils/transfer'; -import { CustomITask, isOfflineContactTask, isInMyBehalfITask } from '../types/types'; +import { hasTaskControl } from '../transfer/transferTaskState'; +import { CustomITask, isInMyBehalfITask, isOfflineContactTask } from '../types/types'; import ProfileIdentifierBanner from './profile/ProfileIdentifierBanner'; import { Flex } from '../styles/HrmStyles'; import { isStandaloneITask } from './case/Case'; diff --git a/plugin-hrm-form/src/components/callTypeButtons/CallTypeButtons.tsx b/plugin-hrm-form/src/components/callTypeButtons/CallTypeButtons.tsx index c2502490d6..a3f678e2a0 100644 --- a/plugin-hrm-form/src/components/callTypeButtons/CallTypeButtons.tsx +++ b/plugin-hrm-form/src/components/callTypeButtons/CallTypeButtons.tsx @@ -29,7 +29,7 @@ import { Box, Flex } from '../../styles/HrmStyles'; import { Container, DataCallTypeButton, Label, NonDataCallTypeButton } from '../../styles/callTypeButtons'; import { isNonDataCallType } from '../../states/validationRules'; import NonDataCallTypeDialog from './NonDataCallTypeDialog'; -import { hasTaskControl } from '../../utils/transfer'; +import { hasTaskControl } from '../../transfer/transferTaskState'; import { completeTask } from '../../services/formSubmissionHelpers'; import CallTypeIcon from '../common/icons/CallTypeIcon'; import { Contact, CustomITask, isOfflineContactTask } from '../../types/types'; diff --git a/plugin-hrm-form/src/components/search/ConnectDialog.tsx b/plugin-hrm-form/src/components/search/ConnectDialog.tsx index c98bcc15b0..40fcbc6182 100644 --- a/plugin-hrm-form/src/components/search/ConnectDialog.tsx +++ b/plugin-hrm-form/src/components/search/ConnectDialog.tsx @@ -21,9 +21,9 @@ import { Template } from '@twilio/flex-ui'; import { callTypes } from 'hrm-form-definitions'; import { Row } from '../../styles/HrmStyles'; -import { ConfirmContainer, ConfirmText, CancelButton } from '../../styles/search'; +import { CancelButton, ConfirmContainer, ConfirmText } from '../../styles/search'; import TabPressWrapper from '../TabPressWrapper'; -import { hasTaskControl } from '../../utils/transfer'; +import { hasTaskControl } from '../../transfer/transferTaskState'; import { Contact, CustomITask } from '../../types/types'; type Props = { diff --git a/plugin-hrm-form/src/components/search/SearchResults/index.tsx b/plugin-hrm-form/src/components/search/SearchResults/index.tsx index b860d1dd4a..ec302ca886 100644 --- a/plugin-hrm-form/src/components/search/SearchResults/index.tsx +++ b/plugin-hrm-form/src/components/search/SearchResults/index.tsx @@ -18,46 +18,46 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { Template, Tab as TwilioTab } from '@twilio/flex-ui'; +import { Tab as TwilioTab, Template } from '@twilio/flex-ui'; import InfoIcon from '@material-ui/icons/Info'; import { DefinitionVersionId } from 'hrm-form-definitions'; import ContactPreview from '../ContactPreview'; import CasePreview from '../CasePreview'; -import { SearchContactResult, SearchCaseResult, Contact, Case, CustomITask } from '../../../types/types'; +import { Case, Contact, CustomITask, SearchCaseResult, SearchContactResult } from '../../../types/types'; import { Row } from '../../../styles/HrmStyles'; import { - ResultsHeader, + EmphasisedText, ListContainer, + NoResultTextLink, + ResultsHeader, ScrollableList, + SearchResultWarningContainer, + StyledCount, StyledFormControlLabel, - StyledSwitch, - SwitchLabel, StyledLink, - StyledTabs, StyledResultsContainer, - StyledResultsText, StyledResultsHeader, - EmphasisedText, - StyledCount, - SearchResultWarningContainer, + StyledResultsText, + StyledSwitch, + StyledTabs, + SwitchLabel, Text, - NoResultTextLink, } from '../../../styles/search'; import Pagination from '../../pagination'; -import { getPermissionsForContact, getPermissionsForCase, PermissionActions } from '../../../permissions'; +import { getPermissionsForCase, getPermissionsForContact, PermissionActions } from '../../../permissions'; import { namespace } from '../../../states/storeNamespaces'; import { RootState } from '../../../states'; import { getCurrentTopmostRouteForTask } from '../../../states/routing/getRoute'; +import * as RoutingActions from '../../../states/routing/actions'; import { changeRoute, newOpenModalAction } from '../../../states/routing/actions'; import { AppRoutes, ChangeRouteMode, SearchResultRoute } from '../../../states/routing/types'; import { recordBackendError } from '../../../fullStory'; -import { hasTaskControl } from '../../../utils/transfer'; +import { hasTaskControl } from '../../../transfer/transferTaskState'; import { getUnsavedContact } from '../../../states/contacts/getUnsavedContact'; import { getHrmConfig, getTemplateStrings } from '../../../hrmConfig'; import { createCaseAsyncAction } from '../../../states/case/saveCase'; import asyncDispatch from '../../../states/asyncDispatch'; -import * as RoutingActions from '../../../states/routing/actions'; export const CONTACTS_PER_PAGE = 20; export const CASES_PER_PAGE = 20; diff --git a/plugin-hrm-form/src/components/tabbedForms/BottomBar.tsx b/plugin-hrm-form/src/components/tabbedForms/BottomBar.tsx index 6c2ab76ab5..a2410af029 100644 --- a/plugin-hrm-form/src/components/tabbedForms/BottomBar.tsx +++ b/plugin-hrm-form/src/components/tabbedForms/BottomBar.tsx @@ -30,7 +30,7 @@ import { } from '../../styles/HrmStyles'; import * as RoutingActions from '../../states/routing/actions'; import { completeTask } from '../../services/formSubmissionHelpers'; -import { hasTaskControl } from '../../utils/transfer'; +import { hasTaskControl } from '../../transfer/transferTaskState'; import { RootState } from '../../states'; import { isNonDataCallType } from '../../states/validationRules'; import { recordBackendError } from '../../fullStory'; diff --git a/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx b/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx index cab8dc2ecc..c7d90ba86d 100644 --- a/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx +++ b/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx @@ -28,20 +28,14 @@ import { RootState } from '../../states'; import { completeTask, removeOfflineContact } from '../../services/formSubmissionHelpers'; import { changeRoute, newCloseModalAction, newOpenModalAction } from '../../states/routing/actions'; import { emptyCategories } from '../../states/contacts/reducer'; +import { AppRoutes, ChangeRouteMode, isRouteWithModalSupport, TabbedFormSubroutes } from '../../states/routing/types'; import { - AppRoutes, - CaseRoute, - ChangeRouteMode, - isRouteWithModalSupport, - TabbedFormSubroutes, -} from '../../states/routing/types'; -import { + Case as CaseForm, + Contact, ContactRawJson, CustomITask, - isOfflineContactTask, - Contact, isOfflineContact, - Case as CaseForm, + isOfflineContactTask, } from '../../types/types'; import { Box, Row, StyledTabs, TabbedFormsContainer, TabbedFormTabContainer } from '../../styles/HrmStyles'; import FormTab from '../common/forms/FormTab'; @@ -49,7 +43,7 @@ import IssueCategorizationSectionForm from '../contact/IssueCategorizationSectio import ContactDetailsSectionForm from '../contact/ContactDetailsSectionForm'; import ContactlessTaskTab from './ContactlessTaskTab'; import BottomBar from './BottomBar'; -import { hasTaskControl } from '../../utils/transfer'; +import { hasTaskControl } from '../../transfer/transferTaskState'; import { isNonDataCallType } from '../../states/validationRules'; import CSAMReportButton from './CSAMReportButton'; import CSAMAttachments from './CSAMAttachments'; @@ -66,12 +60,12 @@ import { namespace } from '../../states/storeNamespaces'; import Search from '../search'; import { getCurrentBaseRoute, getCurrentTopmostRouteForTask } from '../../states/routing/getRoute'; import { CaseLayout } from '../../styles/case'; -import Case, { OwnProps as CaseProps } from '../case/Case'; +import Case from '../case/Case'; import { ContactMetadata } from '../../states/contacts/types'; import SearchResultsBackButton from '../search/SearchResults/SearchResultsBackButton'; import ContactAddedToCaseBanner from '../caseMergingBanners/ContactAddedToCaseBanner'; import ContactRemovedFromCaseBanner from '../caseMergingBanners/ContactRemovedFromCaseBanner'; -import { getHrmConfig, getAseloFeatureFlags, getTemplateStrings } from '../../hrmConfig'; +import { getAseloFeatureFlags, getHrmConfig, getTemplateStrings } from '../../hrmConfig'; import { recordBackendError, recordingErrorHandler } from '../../fullStory'; import { DetailsContext } from '../../states/contacts/contactDetails'; import ContactDetails from '../contact/ContactDetails'; diff --git a/plugin-hrm-form/src/components/transfer/AcceptTransferButton.tsx b/plugin-hrm-form/src/components/transfer/AcceptTransferButton.tsx index c59b8a5573..19adfba34c 100644 --- a/plugin-hrm-form/src/components/transfer/AcceptTransferButton.tsx +++ b/plugin-hrm-form/src/components/transfer/AcceptTransferButton.tsx @@ -16,10 +16,10 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TaskHelper, Template, ITask, ThemeProps } from '@twilio/flex-ui'; +import { ITask, TaskHelper, Template, ThemeProps } from '@twilio/flex-ui'; import { TransferStyledButton } from '../../styles/HrmStyles'; -import { closeCallOriginal } from '../../utils/transfer'; +import { closeCallOriginal } from '../../transfer/transferTaskState'; import HrmTheme from '../../styles/HrmTheme'; const handleAcceptTransfer = async (task: ITask) => { diff --git a/plugin-hrm-form/src/components/transfer/RejectTransferButton.tsx b/plugin-hrm-form/src/components/transfer/RejectTransferButton.tsx index ab2f104c70..b799cad1d7 100644 --- a/plugin-hrm-form/src/components/transfer/RejectTransferButton.tsx +++ b/plugin-hrm-form/src/components/transfer/RejectTransferButton.tsx @@ -16,10 +16,10 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TaskHelper, Template, ITask, ThemeProps } from '@twilio/flex-ui'; +import { ITask, TaskHelper, Template, ThemeProps } from '@twilio/flex-ui'; import { TransferStyledButton } from '../../styles/HrmStyles'; -import { closeCallSelf } from '../../utils/transfer'; +import { closeCallSelf } from '../../transfer/transferTaskState'; import HrmTheme from '../../styles/HrmTheme'; const handleRejectTransfer = async (task: ITask) => { diff --git a/plugin-hrm-form/src/components/transfer/TransferButton.tsx b/plugin-hrm-form/src/components/transfer/TransferButton.tsx index 21ccfa293e..ccc26966fb 100644 --- a/plugin-hrm-form/src/components/transfer/TransferButton.tsx +++ b/plugin-hrm-form/src/components/transfer/TransferButton.tsx @@ -16,9 +16,9 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { Actions, Template, TaskContextProps, withTaskContext } from '@twilio/flex-ui'; +import { Actions, TaskContextProps, Template, withTaskContext } from '@twilio/flex-ui'; -import { canTransferConference } from '../../utils/transfer'; +import { canTransferConference } from '../../transfer/transferTaskState'; import { TransferStyledButton } from '../../styles/HrmStyles'; import HhrTheme from '../../styles/HrmTheme'; diff --git a/plugin-hrm-form/src/conference/setUpConferenceActions.tsx b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx index b5ad30880c..1c0cac77ea 100644 --- a/plugin-hrm-form/src/conference/setUpConferenceActions.tsx +++ b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx @@ -15,9 +15,9 @@ */ import React from 'react'; -import { Actions, ITask, NotificationType, Notifications, Template } from '@twilio/flex-ui'; +import { Actions, ITask, Notifications, NotificationType, Template } from '@twilio/flex-ui'; -import { hasTaskControl, isTransferring } from '../utils/transfer'; +import { hasTaskControl, isTransferring } from '../transfer/transferTaskState'; import { TransfersNotifications } from '../transfer/setUpTransferActions'; export const ConferenceNotifications = { diff --git a/plugin-hrm-form/src/states/storeNamespaces.ts b/plugin-hrm-form/src/states/storeNamespaces.ts index 31ba9e7607..5781d1972e 100644 --- a/plugin-hrm-form/src/states/storeNamespaces.ts +++ b/plugin-hrm-form/src/states/storeNamespaces.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -// Register your redux store under a unique namespace +// Deprecated store aliases, we should be using the RootState type instead export const namespace = 'plugin-hrm-form'; export const contactFormsBase = 'activeContacts'; export const caseListBase = 'caseList'; diff --git a/plugin-hrm-form/src/transfer/formDataTransfer.ts b/plugin-hrm-form/src/transfer/formDataTransfer.ts new file mode 100644 index 0000000000..bf207767b1 --- /dev/null +++ b/plugin-hrm-form/src/transfer/formDataTransfer.ts @@ -0,0 +1,70 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { ITask, Manager } from '@twilio/flex-ui'; + +import { ContactState, releaseContact } from '../states/contacts/existingContacts'; +import { getAseloFeatureFlags, getHrmConfig } from '../hrmConfig'; +import asyncDispatch from '../states/asyncDispatch'; +import { connectToCaseAsyncAction, updateContactInHrmAsyncAction } from '../states/contacts/saveContact'; +import selectContactByTaskSid from '../states/contacts/selectContactByTaskSid'; +import { RootState } from '../states'; + +/** + * Ensures the contact is saved in HRM and disconnected from any case it might have been connected to + * @param {*} contactState current contact (or undefined) + * @param task + */ +export const saveFormSharedState = async (contactState: ContactState, { taskSid }: ITask): Promise => { + if (!getAseloFeatureFlags().enable_transfers) return; + const { draftContact, savedContact } = contactState; + const asyncDispatcher = asyncDispatch(Manager.getInstance().store.dispatch); + if (draftContact) { + await asyncDispatcher(updateContactInHrmAsyncAction(savedContact, draftContact, `task-${taskSid}`)); + } + if (savedContact.caseId) { + await asyncDispatcher(connectToCaseAsyncAction(savedContact.id, undefined)); + } +}; + +/** + * Loads contact being transferred from HRM (if there is any) + */ +export const loadFormSharedState = async ({ taskSid, attributes }: ITask): Promise => { + const { store } = Manager.getInstance(); + const rootState = store.getState() as RootState; + if (!getAseloFeatureFlags().enable_transfers) return null; + if (!attributes.transferMeta) { + console.error('This function should not be called on non-transferred task.'); + return null; + } + + // Should have been loaded already in the beforeAcceptTask handler + const contactState = selectContactByTaskSid(rootState, attributes.transferMeta.originalTask); + + if (!contactState) { + console.error('Could not find contact state for original task, aborting loading transferred data'); + return null; + } + + const { savedContact } = contactState; + + savedContact.taskId = taskSid; + savedContact.twilioWorkerId = getHrmConfig().workerSid; + await asyncDispatch(store.dispatch)(updateContactInHrmAsyncAction(savedContact, savedContact, `task-${taskSid}`)); + store.dispatch(releaseContact(savedContact.id, `task-${attributes.transferMeta.originalTask}`)); + return contactState; +}; diff --git a/plugin-hrm-form/src/transfer/setUpTransferActions.tsx b/plugin-hrm-form/src/transfer/setUpTransferActions.tsx index 140596043d..3dc33cd091 100644 --- a/plugin-hrm-form/src/transfer/setUpTransferActions.tsx +++ b/plugin-hrm-form/src/transfer/setUpTransferActions.tsx @@ -29,19 +29,18 @@ import { } from '@twilio/flex-ui'; import { callTypes } from 'hrm-form-definitions'; -import * as TransferHelpers from '../utils/transfer'; +import * as TransferHelpers from './transferTaskState'; import { transferModes } from '../states/DomainConstants'; import { recordEvent } from '../fullStory'; -import { loadFormSharedState, saveFormSharedState } from '../utils/sharedState'; import { transferChatStart } from '../services/ServerlessService'; import { getHrmConfig } from '../hrmConfig'; import { RootState } from '../states'; import { changeRoute } from '../states/routing/actions'; import { reactivateAseloListeners } from '../conversationListeners'; -import { prepopulateForm } from '../utils/prepopulateForm'; import selectContactByTaskSid from '../states/contacts/selectContactByTaskSid'; import { ContactState } from '../states/contacts/existingContacts'; import { ChangeRouteMode } from '../states/routing/types'; +import { loadFormSharedState, saveFormSharedState } from './formDataTransfer'; type SetupObject = ReturnType; type ActionPayload = { task: ITask }; @@ -96,14 +95,14 @@ const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => const { workerSid, counselorName } = setupObject; - // save current form state as sync document (if there is a form) + // save current form state (if there is a form) const contact = getStateContactForms(payload.task.taskSid); if (!contact) return original(payload); - const documentName = await saveFormSharedState(contact, payload.task); + await saveFormSharedState(contact, payload.task); // set metadata for the transfer - await TransferHelpers.setTransferMeta(payload, documentName, counselorName); + await TransferHelpers.setTransferMeta(payload, counselorName); if (TaskHelper.isCallTask(payload.task)) { const disableTransfer = !TransferHelpers.canTransferConference(payload.task); diff --git a/plugin-hrm-form/src/utils/transfer.ts b/plugin-hrm-form/src/transfer/transferTaskState.ts similarity index 95% rename from plugin-hrm-form/src/utils/transfer.ts rename to plugin-hrm-form/src/transfer/transferTaskState.ts index 6d9511f9cf..4ad06fb718 100644 --- a/plugin-hrm-form/src/utils/transfer.ts +++ b/plugin-hrm-form/src/transfer/transferTaskState.ts @@ -14,11 +14,10 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -// eslint-disable-next-line no-unused-vars -import { Actions, ITask, TaskHelper, Manager } from '@twilio/flex-ui'; +import { Actions, ITask, Manager, TaskHelper } from '@twilio/flex-ui'; import type { RootState } from '../states'; -import { transferStatuses, transferModes } from '../states/DomainConstants'; +import { transferModes, transferStatuses } from '../states/DomainConstants'; import { CustomITask, isTwilioTask } from '../types/types'; import { isCallStatusLoading } from '../states/conferencing/callStatus'; @@ -120,12 +119,10 @@ export const setTransferRejected = updateTransferStatus(transferStatuses.rejecte /** * Saves transfer metadata into task attributes * @param {{ task: ITask, options: { mode: string }, targetSid: string }} payload - * @param {string} documentName name to retrieve the form or null if there were no form to save * @param {string} counselorName */ export const setTransferMeta = async ( payload: { task: ITask; options: { mode: string }; targetSid: string }, - documentName: string, counselorName: string, ) => { const { task, options, targetSid } = payload; @@ -144,7 +141,6 @@ export const setTransferMeta = async ( originalConversationSid: task.attributes.conversationSid || task.attributes.channelSid, // save the original conversation sid, so we can cleanup the listeners if transferred succesfully sidWithTaskControl: mode === transferModes.warm ? '' : 'WR00000000000000000000000000000000', // if cold, set control to dummy value so Task Janitor completes this one transferStatus: mode === transferModes.warm ? transferStatuses.transferring : transferStatuses.accepted, - formDocument: documentName, mode, targetType, }, diff --git a/plugin-hrm-form/src/utils/setUpActions.ts b/plugin-hrm-form/src/utils/setUpActions.ts index 977b1b8c4e..e9fbae7302 100644 --- a/plugin-hrm-form/src/utils/setUpActions.ts +++ b/plugin-hrm-form/src/utils/setUpActions.ts @@ -24,12 +24,12 @@ import { populateCurrentDefinitionVersion, updateDefinitionVersion } from '../st import { clearCustomGoodbyeMessage } from '../states/dualWrite/actions'; import * as GeneralActions from '../states/actions'; import { customChannelTypes } from '../states/DomainConstants'; -import * as TransferHelpers from './transfer'; +import * as TransferHelpers from '../transfer/transferTaskState'; import { CustomITask, FeatureFlags } from '../types/types'; import { getAseloFeatureFlags, getHrmConfig } from '../hrmConfig'; import { subscribeAlertOnConversationJoined } from '../notifications/newMessage'; import type { RootState } from '../states'; -import { getTaskLanguage, getNumberFromTask } from './task'; +import { getNumberFromTask, getTaskLanguage } from './task'; import selectContactByTaskSid from '../states/contacts/selectContactByTaskSid'; import { newContact } from '../states/contacts/contactState'; import asyncDispatch from '../states/asyncDispatch'; diff --git a/plugin-hrm-form/src/utils/setUpComponents.tsx b/plugin-hrm-form/src/utils/setUpComponents.tsx index 5f4b69e942..4eabe6b05b 100644 --- a/plugin-hrm-form/src/utils/setUpComponents.tsx +++ b/plugin-hrm-form/src/utils/setUpComponents.tsx @@ -21,7 +21,7 @@ import * as Flex from '@twilio/flex-ui'; import type { FilterDefinitionFactory } from '@twilio/flex-ui/src/components/view/TeamsView'; import { AcceptTransferButton, RejectTransferButton, TransferButton } from '../components/transfer'; -import * as TransferHelpers from './transfer'; +import * as TransferHelpers from '../transfer/transferTaskState'; import EmojiPicker from '../components/emojiPicker'; import CannedResponses from '../components/CannedResponses'; import QueuesStatusWriter from '../components/queuesStatus/QueuesStatusWriter'; @@ -46,7 +46,7 @@ import { TLHPaddingLeft } from '../styles/GlobalOverrides'; import { Container } from '../styles/queuesStatus'; import { FeatureFlags, isInMyBehalfITask, standaloneTaskSid } from '../types/types'; import { colors } from '../channels/colors'; -import { getHrmConfig, getAseloConfigFlags } from '../hrmConfig'; +import { getAseloConfigFlags, getHrmConfig } from '../hrmConfig'; import { AseloMessageInput, AseloMessageList } from '../components/AseloMessaging'; import { namespace, routingBase } from '../states/storeNamespaces'; import { changeRoute } from '../states/routing/actions'; diff --git a/plugin-hrm-form/src/utils/setUpTaskRouterListeners.ts b/plugin-hrm-form/src/utils/setUpTaskRouterListeners.ts index 87f23ecc20..fa0b3bfbd3 100644 --- a/plugin-hrm-form/src/utils/setUpTaskRouterListeners.ts +++ b/plugin-hrm-form/src/utils/setUpTaskRouterListeners.ts @@ -14,11 +14,11 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { Manager, TaskHelper, StateHelper, ITask } from '@twilio/flex-ui'; +import { ITask, Manager, StateHelper, TaskHelper } from '@twilio/flex-ui'; import type { Conversation } from '@twilio/conversations'; import { FeatureFlags } from '../types/types'; -import * as TransferHelpers from './transfer'; +import * as TransferHelpers from '../transfer/transferTaskState'; import { deactivateAseloListeners } from '../conversationListeners'; const removeConversationListeners = (conversation: Conversation) => { diff --git a/plugin-hrm-form/src/utils/sharedState.ts b/plugin-hrm-form/src/utils/sharedState.ts index 9dd0fa6988..3677f0207d 100644 --- a/plugin-hrm-form/src/utils/sharedState.ts +++ b/plugin-hrm-form/src/utils/sharedState.ts @@ -15,107 +15,12 @@ */ import SyncClient from 'twilio-sync'; -import { CallTypes } from 'hrm-form-definitions'; -import { ITask, Manager } from '@twilio/flex-ui'; -import { recordBackendError } from '../fullStory'; import { issueSyncToken } from '../services/ServerlessService'; -import { getAseloFeatureFlags, getDefinitionVersions, getHrmConfig, getTemplateStrings } from '../hrmConfig'; -import { CSAMReportEntry, Contact } from '../types/types'; -import { ContactMetadata } from '../states/contacts/types'; -import { ChannelTypes } from '../states/DomainConstants'; -import { ResourceReferral } from '../states/contacts/resourceReferral'; -import { ContactState } from '../states/contacts/existingContacts'; -import { newContact } from '../states/contacts/contactState'; -import selectContactByTaskSid from '../states/contacts/selectContactByTaskSid'; -import { RootState } from '../states'; -import { getUnsavedContact } from '../states/contacts/getUnsavedContact'; -import asyncDispatch from '../states/asyncDispatch'; -import { connectToCaseAsyncAction, updateContactInHrmAsyncAction } from '../states/contacts/saveContact'; - -// Legacy type previously used for unsaved contact forms, kept around to ensure transfers are compatible between new & old clients -// Not much point in replacing the use of this type in the shared state, since we will drop use of shared state in favour of the HRM DB for managing transfer state soon anyway -type TaskEntry = { - helpline: string; - callType: CallTypes; - childInformation: { [key: string]: string | boolean }; - callerInformation: { [key: string]: string | boolean }; - caseInformation: { [key: string]: string | boolean }; - contactlessTask: { - channel: ChannelTypes; - date?: string; - time?: string; - createdOnBehalfOf?: string; - [key: string]: string | boolean; - }; - categories: string[]; - referrals?: ResourceReferral[]; - csamReports: CSAMReportEntry[]; - metadata: ContactMetadata; - reservationSid?: string; -}; -type TransferForm = TaskEntry & { draft: ContactMetadata['draft'] }; +import { getAseloFeatureFlags, getTemplateStrings } from '../hrmConfig'; let sharedStateClient: SyncClient; -const transferFormCategoriesToContactCategories = ( - transferFormCategories: TaskEntry['categories'], -): Contact['rawJson']['categories'] => { - if (!transferFormCategories) return undefined; - const contactCategories = {}; - transferFormCategories.forEach(transferFormCategories => { - const [, category, subCategory] = transferFormCategories.split('.'); - contactCategories[category] = [...(contactCategories[category] ?? []), subCategory]; - }); - return contactCategories; -}; - -const transferFormToContactState = (transferForm: TransferForm, baselineContact: Contact): ContactState => { - const { metadata, helpline, csamReports, referrals, reservationSid, ...form } = transferForm; - return { - savedContact: { - ...baselineContact, - helpline, - csamReports, - referrals, - rawJson: { - ...form, - contactlessTask: form.contactlessTask as Contact['rawJson']['contactlessTask'], - categories: transferFormCategoriesToContactCategories(form.categories), - }, - conversationMedia: [], - }, - metadata: { - ...metadata, - draft: form.draft, - }, - references: new Set(), - }; -}; - -const contactFormCategoriesToTransferFormCategories = ( - contactCategories: Contact['rawJson']['categories'], -): TaskEntry['categories'] => { - if (!contactCategories) return undefined; - return Object.entries(contactCategories).flatMap(([category, subCategories]) => - subCategories.map(subCategory => `categories.${category}.${subCategory}`), - ); -}; - -const contactToTransferForm = ({ savedContact, draftContact, metadata }: ContactState): TransferForm => { - const { helpline, csamReports, referrals, rawJson } = getUnsavedContact(savedContact, draftContact); - const { draft } = metadata; - return { - helpline, - csamReports, - referrals, - ...rawJson, - categories: contactFormCategoriesToTransferFormCategories(rawJson?.categories), - draft, - metadata, - }; -}; - export const setUpSharedStateClient = async () => { const updateSharedStateToken = async () => { try { @@ -143,109 +48,6 @@ export const setUpSharedStateClient = async () => { const isSharedStateClientConnected = sharedStateClient => sharedStateClient && sharedStateClient.connectionState === 'connected'; -const DOCUMENT_TTL_SECONDS = 24 * 60 * 60; // 24 hours - -/** - * Saves the actual form into the Sync Client - * @param {*} contactState current contact (or undefined) - * @param task - */ -export const saveFormSharedState = async (contactState: ContactState, task: ITask): Promise => { - if (!getAseloFeatureFlags().enable_transfers) return null; - const { draftContact, savedContact } = contactState; - const asyncDispatcher = asyncDispatch(Manager.getInstance().store.dispatch); - if (draftContact) { - await asyncDispatcher(updateContactInHrmAsyncAction(savedContact, draftContact, task.taskSid)); - } - if (savedContact.caseId) { - await asyncDispatcher(connectToCaseAsyncAction(savedContact.id, undefined)); - } - console.log('Saved form to HRM', contactState.savedContact.id); - try { - if (!isSharedStateClientConnected(sharedStateClient)) { - console.error('Error with Sync Client connection. Sync Client object is: ', sharedStateClient); - recordBackendError('Save Form Shared State', new Error('Sync Client Disconnected')); - } - - console.log('Shared state client is connected.'); - const documentName = contactState ? `pending-form-${task.taskSid}` : null; - - console.log('documentName', documentName, 'contactState', contactState); - if (documentName) { - const document = await sharedStateClient.document(documentName); - console.log('Saving form to shared state', documentName); - await document.set(contactToTransferForm(contactState), { ttl: DOCUMENT_TTL_SECONDS }); // set time to live to 24 hours - console.log('Saved form to shared state', documentName); - return documentName; - } - console.error('Could not save form to shared state, no document name'); - return null; - } catch (err) { - console.error('Error while saving form to shared state', err); - return null; - } -}; - -/** - * Restores the contact form from Sync Client (if there is any) - */ -export const loadFormSharedState = async (task: ITask): Promise => { - const { store } = Manager.getInstance(); - if (!getAseloFeatureFlags().enable_transfers) return null; - if (!task.attributes.transferMeta) { - console.error('This function should not be called on non-transferred task.'); - return null; - } - - // Should have been loaded already in the beforeAcceptTask handler - let contactState = selectContactByTaskSid(store.getState() as RootState, task.attributes.transferMeta.originalTask); - - if (!contactState) { - console.error('Could not find contact state for original task, aborting loading transferred data'); - return null; - } - - try { - if (isSharedStateClientConnected(sharedStateClient)) { - const documentName = task.attributes.transferMeta.formDocument; - if (documentName) { - const document = await sharedStateClient.document(documentName); - const transferredContactState = transferFormToContactState( - document.data as TransferForm, - contactState.savedContact ?? newContact(getDefinitionVersions().currentDefinitionVersion, task), - ); - - const updatedContact = { - ...contactState.savedContact, - ...transferredContactState.savedContact, - rawJson: { - ...contactState.savedContact.rawJson, - ...transferredContactState.savedContact.rawJson, - }, - id: contactState.savedContact.id, - accountSid: contactState.savedContact.accountSid, - }; - contactState = { - ...transferredContactState, - savedContact: updatedContact, - }; - } - } else { - console.error('Error with Sync Client connection. Sync Client object is: ', sharedStateClient); - recordBackendError('Load Form Shared State', new Error('Sync Client Disconnected')); - } - contactState.savedContact.taskId = task.taskSid; - contactState.savedContact.twilioWorkerId = getHrmConfig().workerSid; - await asyncDispatch(store.dispatch)( - updateContactInHrmAsyncAction(contactState.savedContact, contactState.savedContact, task.taskSid), - ); - } catch (err) { - console.error('Error while loading form from shared state', err); - throw err; - } - return contactState; -}; - /** * This function creates an object with all parseable attributes from the original Twilio Task. * diff --git a/plugin-hrm-form/src/utils/shouldSendInsightsData.ts b/plugin-hrm-form/src/utils/shouldSendInsightsData.ts index 99cf3088ef..b8638e5dbe 100644 --- a/plugin-hrm-form/src/utils/shouldSendInsightsData.ts +++ b/plugin-hrm-form/src/utils/shouldSendInsightsData.ts @@ -15,7 +15,7 @@ */ import { CustomITask } from '../types/types'; import { getAseloFeatureFlags } from '../hrmConfig'; -import * as TransferHelpers from './transfer'; +import * as TransferHelpers from '../transfer/transferTaskState'; /* eslint-disable sonarjs/prefer-single-boolean-return */ export const shouldSendInsightsData = (task: CustomITask) => {