diff --git a/e2e-tests/browser.ts b/e2e-tests/browser.ts index db82cbfa3b..b082ee48d8 100644 --- a/e2e-tests/browser.ts +++ b/e2e-tests/browser.ts @@ -16,6 +16,7 @@ import { Browser, BrowserContext, Page, chromium } from '@playwright/test'; import { logPageTelemetry } from './browser-logs'; +import { getConfigValue } from './config'; export type SetupPageReturn = { context: BrowserContext; @@ -45,7 +46,9 @@ export const setupContextAndPage = async (browser: Browser): Promise> = { sectionTypeId: 'note' | 'referral' | 'household' | 'perpetrator' | 'incident' | 'document'; @@ -24,26 +23,27 @@ export type CaseSectionForm> = { }; export const caseHome = (page: Page) => { - const caseHomeArea = page.locator('div.Twilio-CRMContainer'); + // const caseHomeArea = page.locator('div.Twilio-CRMContainer'); const selectors = { addSectionButton: (sectionTypeId: string) => - caseHomeArea.locator( + page.locator( `//button[@data-testid='Case-${ sectionTypeId.charAt(0).toUpperCase() + sectionTypeId.slice(1) }-AddButton']`, ), - formInput: (itemId: string) => caseHomeArea.locator(`input#${itemId}`), - formSelect: (itemId: string) => caseHomeArea.locator(`select#${itemId}`), - formTextarea: (itemId: string) => caseHomeArea.locator(`textarea#${itemId}`), - saveCaseItemButton: caseHomeArea.locator( - `//button[@data-testid='Case-AddEditItemScreen-SaveItem']`, - ), - saveCaseAndEndButton: caseHomeArea.locator(`//button[@data-testid='BottomBar-SaveCaseAndEnd']`), - getNewCaseId: caseHomeArea.locator(`//p[@data-testid='Case-DetailsHeaderCaseId']`), + formItem: (itemId: string) => page.locator(`#${itemId}`), + formInput: (itemId: string) => page.locator(`input#${itemId}`), + formSelect: (itemId: string) => page.locator(`select#${itemId}`), + formTextarea: (itemId: string) => page.locator(`textarea#${itemId}`), + saveCaseItemButton: page.locator(`//button[@data-testid='Case-AddEditItemScreen-SaveItem']`), + saveCaseAndEndButton: page.locator(`//button[@data-testid='BottomBar-SaveCaseAndEnd']`), + getNewCaseId: page.locator(`//p[@data-testid='Case-DetailsHeaderCaseId']`), }; async function fillSectionForm({ items }: CaseSectionForm) { for (let [itemId, value] of Object.entries(items)) { + await expect(selectors.formItem(itemId)).toBeVisible(); + await expect(selectors.formItem(itemId)).toBeEnabled(); if (await selectors.formInput(itemId).count()) { await selectors.formInput(itemId).fill(value); } else if (await selectors.formSelect(itemId).count()) { @@ -54,24 +54,32 @@ export const caseHome = (page: Page) => { } } - async function addCaseSection(sectionForm: CaseSectionForm) { - const addButton = selectors.addSectionButton(sectionForm.sectionTypeId); - await addButton.click(); + async function addCaseSection(section: CaseSectionForm) { + const sectionId = + section.sectionTypeId.charAt(0).toUpperCase() + section.sectionTypeId.slice(1); + const newSectionButton = selectors.addSectionButton(sectionId); + await newSectionButton.waitFor({ state: 'visible' }); + await expect(newSectionButton).toContainText(sectionId); + await newSectionButton.click(); + await fillSectionForm(section); - await fillSectionForm(sectionForm); - - const saveButton = selectors.saveCaseItemButton; - await saveButton.click(); - - /** - * Fix to addOfflineContact tests flakiness - * TODO: investigate root cause - */ - await delay(300); + const saveItemButton = selectors.saveCaseItemButton; + await expect(saveItemButton).toBeVisible(); + await expect(saveItemButton).toBeEnabled(); + const responsePromise = page.waitForResponse('**/cases/**'); + await saveItemButton.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); } async function saveCaseAndEnd() { + const responsesPromise = Promise.all([ + page.waitForResponse('**/cases/**'), + page.waitForResponse('**/contacts/**'), + ]); await selectors.saveCaseAndEndButton.click(); + const responses = await responsesPromise; + responses.forEach((response) => expect(response.ok()).toBeTruthy()); } const { getNewCaseId } = selectors; diff --git a/e2e-tests/caseList.ts b/e2e-tests/caseList.ts index 43f907e7eb..2a07450030 100644 --- a/e2e-tests/caseList.ts +++ b/e2e-tests/caseList.ts @@ -16,6 +16,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { Page, expect } from '@playwright/test'; +import { caseHome } from './case'; export type Filter = | 'Status' @@ -63,9 +64,6 @@ export const caseList = (page: Page) => { caseEditButton: caseListPage.locator(`//button[@data-testid='Case-EditButton']`), //Case Section view - formInput: (itemId: string) => caseListPage.locator(`input#${itemId}`), - formSelect: (itemId: string) => caseListPage.locator(`select#${itemId}`), - formTextarea: (itemId: string) => caseListPage.locator(`textarea#${itemId}`), saveCaseItemButton: caseListPage.locator( `//button[@data-testid='Case-AddEditItemScreen-SaveItem']`, ), @@ -115,6 +113,7 @@ export const caseList = (page: Page) => { await expect(openCaseButton).toContainText(/^OpenCase[0-9]+$/); await openCaseButton.click(); console.log('Opened first case in the results'); + return caseHome(page); } //Check print view @@ -130,34 +129,6 @@ export const caseList = (page: Page) => { console.log('Close Case Print'); } - async function fillSectionForm({ items }: CaseSectionForm) { - for (let [itemId, value] of Object.entries(items)) { - if (await selectors.formInput(itemId).count()) { - await selectors.formInput(itemId).fill(value); - } else if (await selectors.formSelect(itemId).count()) { - await selectors.formSelect(itemId).selectOption(value); - } else if (await selectors.formTextarea(itemId).count()) { - await selectors.formTextarea(itemId).fill(value); - } else throw new Error(`Control ${itemId} not found`); - } - } - - //Add a section (and close) - async function addCaseSection(section: CaseSectionForm) { - const sectionId = - section.sectionTypeId.charAt(0).toUpperCase() + section.sectionTypeId.slice(1); - const newSectionButton = selectors.addSectionButton(sectionId); - await newSectionButton.waitFor({ state: 'visible' }); - await expect(newSectionButton).toContainText(sectionId); - await newSectionButton.click(); - - await fillSectionForm(section); - - const saveItemButton = selectors.saveCaseItemButton; - await saveItemButton.waitFor({ state: 'visible' }); - await saveItemButton.click(); - } - //Edit Case async function editCase() { const editCaseButton = selectors.caseEditButton; @@ -178,7 +149,10 @@ export const caseList = (page: Page) => { const updateCaseButton = selectors.updateCaseButton; await updateCaseButton.waitFor({ state: 'visible' }); await expect(updateCaseButton).toContainText('Save'); + const responsePromise = page.waitForResponse('**/cases/**'); await updateCaseButton.click(); + await responsePromise; + console.log('Updated Case Summary'); } @@ -209,7 +183,6 @@ export const caseList = (page: Page) => { filterCases, openFirstCaseButton, viewClosePrintView, - addCaseSection, editCase, updateCaseSummary, verifyCaseSummaryUpdated, diff --git a/e2e-tests/chatScripts.ts b/e2e-tests/chatScripts.ts index 7b887aa106..3a63fb7b17 100644 --- a/e2e-tests/chatScripts.ts +++ b/e2e-tests/chatScripts.ts @@ -25,11 +25,9 @@ import { getConfigValue } from './config'; export const defaultScript: ChatStatement[] = [ botStatement( - 'Welcome to the helpline. To help us better serve you, please answer the following three questions.', + 'Welcome. To help us better serve you, please answer the following questions. Are you calling about yourself? Please answer Yes or No.', ), - botStatement('Are you calling about yourself? Please answer Yes or No.'), callerStatement('yes'), - botStatement("Thank you. You can say 'prefer not to answer' (or type X) to any question."), botStatement('How old are you?'), callerStatement('10'), botStatement('What is your gender?'), diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index e8cbfc191d..3e5f7de092 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -51,7 +51,7 @@ const skipDataUpdateEnvs = ['staging', 'production']; const flexEnvs = ['development', 'staging', 'production']; // This is kindof a hack to get the correct default remote webchat url and twilio account info for the local env -const localOverrideEnv = helplineEnv == 'local' ? 'development' : helplineEnv; +export const localOverrideEnv = helplineEnv == 'local' ? 'development' : helplineEnv; export const config: Config = {}; @@ -127,6 +127,7 @@ const configOptions: ConfigOptions = { twilioAccountSid: { envKey: 'TWILIO_ACCOUNT_SID', ssmPath: `/${localOverrideEnv}/twilio/${helplineShortCode.toUpperCase()}/account_sid`, + default: 'AC_FAKE_UI_TEST_ACCOUNT', }, twilioAuthToken: { envKey: 'TWILIO_AUTH_TOKEN', @@ -186,6 +187,11 @@ const configOptions: ConfigOptions = { envKey: 'TEST_NAME', default: () => (getConfigValue('inLambda') ? 'login' : ''), }, + + hrmRoot: { + envKey: 'HRM_ROOT', + default: '', // Default cannot be set up front due to the account sid might not calculated. + }, }; export const setConfigValue = (key: string, value: ConfigValue) => { diff --git a/e2e-tests/contactForm.ts b/e2e-tests/contactForm.ts index cda240f085..d491ae12e3 100644 --- a/e2e-tests/contactForm.ts +++ b/e2e-tests/contactForm.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import { Page } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; export type Categories = Record; @@ -48,9 +48,13 @@ export function contactForm(page: Page) { async function selectTab(tab: ContactFormTab) { const button = selectors.tabButton(tab); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); + const responsePromise = page.waitForResponse('**/contacts/**'); if ((await button.getAttribute('aria-selected')) !== 'true') { await button.click(); } + await responsePromise; } async function fillStandardTab({ id, items }: ContactFormTab) { @@ -75,12 +79,11 @@ export function contactForm(page: Page) { } return { - selectChildCallType: async (allowSkip: boolean = false) => { + selectChildCallType: async () => { const childCallTypeButton = selectors.childCallTypeButton(); - if (!(await childCallTypeButton.isVisible({ timeout: 200 })) && allowSkip) { - return; - } + const responsePromise = page.waitForResponse('**/contacts/**'); await childCallTypeButton.click(); + await responsePromise; }, fill: async (tabs: ContactFormTab[]) => { for (const tab of tabs) { @@ -98,9 +101,14 @@ export function contactForm(page: Page) { await selectTab(tab); if (saveAndAddToCase) { + const responsePromise = page.waitForResponse('**/connectToCase'); await selectors.saveAndAddToCaseButton.click(); - await page.waitForResponse('**/connectToCase'); - } else await selectors.saveContactButton.click(); + await responsePromise; + } else { + const responsePromise = page.waitForResponse('**/contacts/**'); + await selectors.saveContactButton.click(); + await responsePromise; + } await selectors.tabButton(tab).waitFor({ state: 'detached' }); }, diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 1d718f5e44..085441a910 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -17,9 +17,10 @@ /* eslint-disable import/no-extraneous-dependencies */ import { FullConfig } from '@playwright/test'; import { differenceInMilliseconds } from 'date-fns'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { oktaSsoLoginViaApi, oktaSsoLoginViaGui } from './okta/sso-login'; -import { getConfigValue, initConfig } from './config'; +import { oktaSsoLoginViaApi } from './okta/sso-login'; +import { getConfigValue, initConfig, localOverrideEnv } from './config'; +import { getSidForWorker } from './twilio/worker'; +import { clearOfflineTask } from './hrm/clearOfflineTask'; async function globalSetup(config: FullConfig) { const start = new Date(); @@ -31,19 +32,29 @@ async function globalSetup(config: FullConfig) { } await initConfig(); - await oktaSsoLoginViaApi( + const flexToken = await oktaSsoLoginViaApi( getConfigValue('baseURL') as string, getConfigValue('oktaUsername') as string, getConfigValue('oktaPassword') as string, getConfigValue('twilioAccountSid') as string, ); - /* await oktaSsoLoginViaGui( - config, - getConfigValue('oktaUsername') ?? 'NOT SET', - getConfigValue('oktaPassword') ?? 'NOT SET', - ); - */ - + const workerSid = await getSidForWorker(getConfigValue('oktaUsername') as string); + if (workerSid) { + await clearOfflineTask( + (getConfigValue('hrmRoot') as string) || + `https://hrm-${localOverrideEnv}.tl.techmatters.org/v0/accounts/${getConfigValue( + 'twilioAccountSid', + )}`, + workerSid, + flexToken, + ); + } else { + console.warn( + `Could not find worker with username ${getConfigValue( + 'oktaUsername', + )} to clear out offline tasks.`, + ); + } process.env.ARTIFACT_PATH = config.projects[0].outputDir; console.log( 'Global setup completed', diff --git a/e2e-tests/hrm/clearOfflineTask.ts b/e2e-tests/hrm/clearOfflineTask.ts new file mode 100644 index 0000000000..c8880d9e10 --- /dev/null +++ b/e2e-tests/hrm/clearOfflineTask.ts @@ -0,0 +1,67 @@ +/** + * 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 { request } from '@playwright/test'; + +// Clears out any residual offline task data for a worker so the test env is clean +export const clearOfflineTask = async (hrmRoot: string, workerSid: string, flexToken: string) => { + const apiRequest = await request.newContext(); + new URL(`${hrmRoot}/contacts/byTaskSid/offline-task-${workerSid}`); + const resp = await apiRequest.get( + `${hrmRoot}/contacts/byTaskSid/offline-contact-task-${workerSid}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${flexToken}`, + }, + }, + ); + if (resp.ok()) { + const contactId: string = (await resp.json()).id; + await apiRequest.patch(`${hrmRoot}/contacts/${contactId}?finalize=false`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${flexToken}`, + }, + // Copied from plugin-hrm-form/src/services/ContactService.ts + data: { + conversationDuration: 0, + rawJson: { + callType: '', + childInformation: {}, + callerInformation: {}, + caseInformation: {}, + categories: {}, + contactlessTask: { + channel: null, + createdOnBehalfOf: null, + date: null, + time: null, + }, + }, + }, + }); + } else { + if (resp.status() === 404) { + console.warn(`No offline task found for worker ${workerSid}, cannot clear out offline tasks`); + return; + } else { + throw new Error( + `Error occurred finding offline tasks for ${workerSid} in HRM: ${resp.status()}`, + ); + } + } +}; diff --git a/e2e-tests/okta/sso-login.ts b/e2e-tests/okta/sso-login.ts index 34625e301f..87425159e7 100644 --- a/e2e-tests/okta/sso-login.ts +++ b/e2e-tests/okta/sso-login.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import { chromium, expect, FullConfig, request } from '@playwright/test'; +import { expect, request } from '@playwright/test'; import { getConfigValue } from '../config'; export function delay(time: number) { @@ -41,7 +41,7 @@ export async function oktaSsoLoginViaApi( username: string, password: string, accountSid: string, -): Promise { +): Promise { const apiRequest = await request.newContext(); // Get the saml location URL const authenticateRequestOptions = { data: { products: ['flex'], resource: homeUrl } }; @@ -96,8 +96,12 @@ export async function oktaSsoLoginViaApi( RelayState: decodeHtmlSymbols(relayState), }, timeout: 600000, // Long timeout in case a local dev server is still starting up + maxRedirects: 0, }, ); + if (flexPageResponse.status() === 303) { + break; + } } catch (err) { const error = err; if (Date.now() > flexTimeoutTime || error.message.indexOf('ECONNREFUSED') === -1) { @@ -108,37 +112,11 @@ export async function oktaSsoLoginViaApi( } } } - expect(flexPageResponse.ok()).toBe(true); - await flexPageResponse.dispose(); //Not sure if this is strictly necessary - + expect(flexPageResponse.status()).toBe(303); + const redirectHeaders = flexPageResponse.headers(); + const redirectURL = new URL(redirectHeaders.location ?? redirectHeaders.Location!); + const resp = await apiRequest.get(redirectURL.toString()); + expect(resp.ok()).toBe(true); await apiRequest.storageState({ path: getConfigValue('storageStatePath') as string }); -} - -export async function oktaSsoLoginViaGui( - config: FullConfig, - username: string, - password: string, -): Promise { - const project = config.projects[0]; - const browser = await chromium.launch(project.use); - console.log('Global setup browser launched'); - const page = await browser.newPage(); - page.goto(project.use.baseURL!, { timeout: 30000 }); - await page.waitForNavigation({ timeout: 30001 }); - const usernameBox = page.locator('input#okta-signin-username'); - const passwordBox = page.locator('input#okta-signin-password'); - const submitButton = page.locator('input#okta-signin-submit'); - await Promise.all([usernameBox.waitFor(), passwordBox.waitFor(), submitButton.waitFor()]); - console.log('Global setup boxes found'); - await usernameBox.fill(username); - await passwordBox.fill(password); - await Promise.all([ - page.waitForNavigation({ timeout: 30000 }), // Waits for the next navigation - submitButton.click(), // Triggers a navigation after a timeout - ]); - const logoImage = page.locator('.Twilio.Twilio-MainHeader img'); - await logoImage.waitFor(); - await expect(logoImage).toHaveAttribute('src', /.*aselo.*/); - await page.context().storageState({ path: getConfigValue('storageStatePath') as string }); - await browser.close(); + return redirectURL.searchParams.get('Token')!; } diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 99c0818f68..3ce2d0811c 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -8,7 +8,7 @@ "deleteChatChannels": "tsx deleteChatChannels.ts", "test": "npx playwright test --workers 1", "test:ui": "npx playwright test --config ui-tests/playwright.ui-test.config.ts", - "test:local": "DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --retries 0 webchat", diff --git a/e2e-tests/tests/caselist.spec.ts b/e2e-tests/tests/caselist.spec.ts index 9b3267adbd..d528094acd 100644 --- a/e2e-tests/tests/caselist.spec.ts +++ b/e2e-tests/tests/caselist.spec.ts @@ -48,21 +48,21 @@ test.describe.serial('Open and Edit a Case in Case List page', () => { //for Categories filter, 2 valid options are required await page.filterCases('Categories', 'Accessibility', 'Education'); - await page.openFirstCaseButton(); + const caseHomePage = await page.openFirstCaseButton(); // Open notifications cover up the print icon :facepalm await notificationBar(pluginPage).dismissAllNotifications(); await page.viewClosePrintView(); - await page.addCaseSection({ + await caseHomePage.addCaseSection({ sectionTypeId: 'note', items: { note: 'TEST NOTE', }, }); - await page.addCaseSection({ + await caseHomePage.addCaseSection({ sectionTypeId: 'household', items: { firstName: 'FIRST NAME', diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 9f55676ba2..ece55adb96 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -52,7 +52,7 @@ test.describe.serial('Offline Contact (with Case)', () => { console.log('Starting filling form'); const form = contactForm(pluginPage); - await form.selectChildCallType(true); + await form.selectChildCallType(); await form.fill([ { id: 'contactlessTask', diff --git a/e2e-tests/tests/referrableResources.spec.ts b/e2e-tests/tests/referrableResources.spec.ts index e175baca76..cae9ec261e 100644 --- a/e2e-tests/tests/referrableResources.spec.ts +++ b/e2e-tests/tests/referrableResources.spec.ts @@ -51,7 +51,7 @@ test.describe.serial('Resource Search', () => { console.log('Starting filling form'); const form = contactForm(pluginPage); - await form.selectChildCallType(true); + await form.selectChildCallType(); await form.fill([ { id: 'contactlessTask', diff --git a/e2e-tests/twilio/worker.ts b/e2e-tests/twilio/worker.ts new file mode 100644 index 0000000000..bef984e6dd --- /dev/null +++ b/e2e-tests/twilio/worker.ts @@ -0,0 +1,38 @@ +/** + * 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 { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies +import twilio from 'twilio'; + +export const getSidForWorker = async (friendlyName: string): Promise => { + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const twilioClient = twilio(accountSid, authToken); + + const workspaces = await twilioClient.taskrouter.v1.workspaces.list(); + if (!workspaces) { + throw new Error(`Workspaces not found.`); + } + for (const workspace of workspaces) { + const workersInWorkspace = await workspace.workers().list(); + const sid = workersInWorkspace.find((worker) => worker.friendlyName === friendlyName)?.sid; + if (sid) { + return sid; + } + } + return undefined; +}; diff --git a/plugin-hrm-form/src/___tests__/components/OfflineContact/AddOfflineContactButton.test.tsx b/plugin-hrm-form/src/___tests__/components/OfflineContact/AddOfflineContactButton.test.tsx index c8a7f0ff35..9ceaf1479b 100644 --- a/plugin-hrm-form/src/___tests__/components/OfflineContact/AddOfflineContactButton.test.tsx +++ b/plugin-hrm-form/src/___tests__/components/OfflineContact/AddOfflineContactButton.test.tsx @@ -29,9 +29,11 @@ import { AddOfflineContactButton } from '../../../components/OfflineContact'; import { rerenderAgentDesktop } from '../../../rerenderView'; import { createContact } from '../../../services/ContactService'; import { Contact } from '../../../types/types'; -import { configurationBase, namespace, routingBase } from '../../../states/storeNamespaces'; +import { namespace } from '../../../states/storeNamespaces'; +import { RootState } from '../../../states'; +import { RecursivePartial } from '../../RecursivePartial'; -let v1; +let mockV1; jest.mock('../../../services/ServerlessService'); jest.mock('../../../rerenderView', () => ({ @@ -46,7 +48,6 @@ jest.mock('@twilio/flex-ui', () => ({ invokeAction: jest.fn(), }, })); -mockPartialConfiguration({ workerSid: 'mock-worker' }); // eslint-disable-next-line react-hooks/rules-of-hooks const { mockFetchImplementation, mockReset, buildBaseURL } = useFetchDefinitions(); const mockInvokeAction = Actions.invokeAction as jest.MockedFunction; @@ -57,7 +58,31 @@ beforeAll(async () => { const formDefinitionsBaseUrl = buildBaseURL(DefinitionVersionId.v1); await mockFetchImplementation(formDefinitionsBaseUrl); - v1 = await loadDefinition(formDefinitionsBaseUrl); + mockV1 = await loadDefinition(formDefinitionsBaseUrl); + mockPartialConfiguration({ workerSid: 'mock-worker' }); + baseState = { + flex: { + view: { selectedTaskSid: '123' }, + }, + [namespace]: { + configuration: { + currentDefinitionVersion: mockV1, + }, + routing: { + isAddingOfflineContact: false, + }, + activeContacts: { + existingContacts: { + contact1: { + savedContact: { + id: 'contact1', + taskId: '123', + }, + }, + }, + }, + }, + }; }); beforeEach(async () => { @@ -68,25 +93,16 @@ beforeEach(async () => { expect.extend(toHaveNoViolations); +let baseState: RecursivePartial; + const mockStore = configureMockStore([]); test('click on button', async () => { mockCreateContact.mockImplementation((contact: Contact) => { console.log('Creating contact', contact); return Promise.resolve(contact); }); - const store = mockStore({ - flex: { - view: { selectedTaskSid: '123' }, - }, - [namespace]: { - [configurationBase]: { - currentDefinitionVersion: v1, - }, - [routingBase]: { - isAddingOfflineContact: false, - }, - }, - }); + + const store = mockStore(baseState); render( @@ -116,19 +132,21 @@ test('click on button', async () => { }); test('button should be disabled (default task exists)', () => { - const store = mockStore({ + const state: RecursivePartial = { + ...baseState, flex: { view: { selectedTaskSid: undefined }, + ...baseState.flex, }, [namespace]: { - [configurationBase]: { - currentDefinitionVersion: v1, - }, - [routingBase]: { + ...baseState[namespace], + routing: { isAddingOfflineContact: true, }, }, - }); + }; + + const store = mockStore(state); const recreateContactState = jest.fn(); @@ -150,19 +168,14 @@ test('button should be disabled (default task exists)', () => { const Wrapped = withTheme(props => ); test('a11y', async () => { - const store = mockStore({ + const state: RecursivePartial = { + ...baseState, flex: { view: { selectedTaskSid: '123', activeView: 'some-view' }, + ...baseState.flex, }, - [namespace]: { - [configurationBase]: { - currentDefinitionVersion: v1, - }, - [routingBase]: { - isAddingOfflineContact: false, - }, - }, - }); + }; + const store = mockStore(state); const wrapper = mount( diff --git a/plugin-hrm-form/src/___tests__/states/routing/reducer.test.ts b/plugin-hrm-form/src/___tests__/states/routing/reducer.test.ts index 3e16fa50a7..175653d3d1 100644 --- a/plugin-hrm-form/src/___tests__/states/routing/reducer.test.ts +++ b/plugin-hrm-form/src/___tests__/states/routing/reducer.test.ts @@ -572,7 +572,17 @@ describe('test reducer (specific actions)', () => { const result = reduce(stateWithTask, { type: LOAD_CONTACT_FROM_HRM_BY_TASK_ID_ACTION_FULFILLED, payload: { - contact: { ...VALID_EMPTY_CONTACT, taskId: offlineContactTaskSid }, + contact: { + ...VALID_EMPTY_CONTACT, + taskId: offlineContactTaskSid, + rawJson: { + ...VALID_EMPTY_CONTACT.rawJson, + contactlessTask: { + ...VALID_EMPTY_CONTACT.rawJson.contactlessTask, + createdOnBehalfOf: 'workerSid', + }, + }, + }, metadata: VALID_EMPTY_METADATA, }, } as any); diff --git a/plugin-hrm-form/src/components/CustomCRMContainer.tsx b/plugin-hrm-form/src/components/CustomCRMContainer.tsx index 98aac9ad57..d1bcc3f0ff 100644 --- a/plugin-hrm-form/src/components/CustomCRMContainer.tsx +++ b/plugin-hrm-form/src/components/CustomCRMContainer.tsx @@ -15,9 +15,10 @@ */ /* eslint-disable react/prop-types */ -import React, { useEffect } from 'react'; +import React, { Dispatch, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { ITask, withTaskContext } from '@twilio/flex-ui'; +import { DefinitionVersion } from 'hrm-form-definitions'; import TaskView from './TaskView'; import { Absolute } from '../styles/HrmStyles'; @@ -26,7 +27,11 @@ import { populateCounselorsState } from '../states/configuration/actions'; import { RootState } from '../states'; import { OfflineContactTask } from '../types/types'; import getOfflineContactTaskSid from '../states/contacts/offlineContactTaskSid'; -import { namespace, routingBase } from '../states/storeNamespaces'; +import { namespace } from '../states/storeNamespaces'; +import asyncDispatch from '../states/asyncDispatch'; +import { createContactAsyncAction } from '../states/contacts/saveContact'; +import { getHrmConfig } from '../hrmConfig'; +import { newContact } from '../states/contacts/contactState'; type OwnProps = { task?: ITask; @@ -35,12 +40,20 @@ type OwnProps = { // eslint-disable-next-line no-use-before-define type Props = OwnProps & ConnectedProps; -const CustomCRMContainer: React.FC = ({ selectedTaskSid, isAddingOfflineContact, task, dispatch }) => { +const CustomCRMContainer: React.FC = ({ + selectedTaskSid, + isAddingOfflineContact, + task, + populateCounselorList, + currentOfflineContact, + definitionVersion, + loadOrCreateDraftOfflineContact, +}) => { useEffect(() => { const fetchPopulateCounselors = async () => { try { const counselorsList = await populateCounselors(); - dispatch(populateCounselorsState(counselorsList)); + populateCounselorList(counselorsList); } catch (err) { // TODO (Gian): probably we need to handle this in a nicer way console.error(err.message); @@ -48,7 +61,13 @@ const CustomCRMContainer: React.FC = ({ selectedTaskSid, isAddingOfflineC }; fetchPopulateCounselors(); - }, [dispatch]); + }, [populateCounselorList]); + + useEffect(() => { + if (!currentOfflineContact && definitionVersion) { + loadOrCreateDraftOfflineContact(definitionVersion); + } + }, [currentOfflineContact, definitionVersion, loadOrCreateDraftOfflineContact]); const offlineContactTask: OfflineContactTask = { taskSid: getOfflineContactTaskSid(), @@ -71,15 +90,31 @@ const CustomCRMContainer: React.FC = ({ selectedTaskSid, isAddingOfflineC CustomCRMContainer.displayName = 'CustomCRMContainer'; -const mapStateToProps = (state: RootState) => { - const { selectedTaskSid } = state.flex.view; - const { isAddingOfflineContact } = state[namespace][routingBase]; +const mapStateToProps = ({ [namespace]: { routing, activeContacts, configuration }, flex }: RootState) => { + const { selectedTaskSid } = flex.view; + const { isAddingOfflineContact } = routing; + const currentOfflineContact = Object.values(activeContacts.existingContacts).find( + contact => contact.savedContact.taskId === getOfflineContactTaskSid(), + ); return { selectedTaskSid, isAddingOfflineContact, + currentOfflineContact, + definitionVersion: configuration.currentDefinitionVersion, + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch) => { + return { + loadOrCreateDraftOfflineContact: (definition: DefinitionVersion) => + asyncDispatch(dispatch)( + createContactAsyncAction(newContact(definition), getHrmConfig().workerSid, getOfflineContactTaskSid()), + ), + populateCounselorList: (listPayload: Awaited>) => + dispatch(populateCounselorsState(listPayload)), }; }; -const connector = connect(mapStateToProps); +const connector = connect(mapStateToProps, mapDispatchToProps); export default withTaskContext(connector(CustomCRMContainer)); diff --git a/plugin-hrm-form/src/components/OfflineContact/AddOfflineContactButton.tsx b/plugin-hrm-form/src/components/OfflineContact/AddOfflineContactButton.tsx index 1fc57f5ef1..e492a226fd 100644 --- a/plugin-hrm-form/src/components/OfflineContact/AddOfflineContactButton.tsx +++ b/plugin-hrm-form/src/components/OfflineContact/AddOfflineContactButton.tsx @@ -26,9 +26,9 @@ import getOfflineContactTaskSid from '../../states/contacts/offlineContactTaskSi import { getHrmConfig } from '../../hrmConfig'; import { newContact } from '../../states/contacts/contactState'; import asyncDispatch from '../../states/asyncDispatch'; -import { createContactAsyncAction } from '../../states/contacts/saveContact'; -import { rerenderAgentDesktop } from '../../rerenderView'; -import { configurationBase, namespace, routingBase } from '../../states/storeNamespaces'; +import { createContactAsyncAction, newRestartOfflineContactAsyncAction } from '../../states/contacts/saveContact'; +import { namespace } from '../../states/storeNamespaces'; +import findContactByTaskSid from '../../states/contacts/findContactByTaskSid'; type OwnProps = {}; @@ -39,6 +39,8 @@ const AddOfflineContactButton: React.FC = ({ isAddingOfflineContact, currentDefinitionVersion, createContactState, + restartContact, + draftOfflineContact, }) => { if (!currentDefinitionVersion) { return null; @@ -46,10 +48,13 @@ const AddOfflineContactButton: React.FC = ({ const onClick = async () => { console.log('Onclick - creating contact'); - createContactState(newContact(currentDefinitionVersion)); - + if (draftOfflineContact) { + await restartContact(draftOfflineContact); + } else { + await createContactState(newContact(currentDefinitionVersion)); + } await Actions.invokeAction('SelectTask', { task: undefined }); - await rerenderAgentDesktop(); + // await rerenderAgentDesktop(); }; return ( @@ -65,20 +70,26 @@ const AddOfflineContactButton: React.FC = ({ AddOfflineContactButton.displayName = 'AddOfflineContactButton'; const mapStateToProps = (state: RootState) => { - const { currentDefinitionVersion } = state[namespace][configurationBase]; - const { isAddingOfflineContact } = state[namespace][routingBase]; + const draftOfflineContact = findContactByTaskSid(state, getOfflineContactTaskSid())?.savedContact; + const { currentDefinitionVersion } = state[namespace].configuration; + const { isAddingOfflineContact } = state[namespace].routing; return { isAddingOfflineContact, currentDefinitionVersion, + draftOfflineContact, }; }; -const mapDispatchToProps = dispatch => ({ - createContactState: (contact: Contact) => { - asyncDispatch(dispatch)(createContactAsyncAction(contact, getHrmConfig().workerSid, getOfflineContactTaskSid())); - }, -}); +const mapDispatchToProps = dispatch => { + const asyncDispatcher = asyncDispatch(dispatch); + return { + createContactState: (contact: Contact) => + asyncDispatcher(createContactAsyncAction(contact, getHrmConfig().workerSid, getOfflineContactTaskSid())), + restartContact: (contact: Contact) => + asyncDispatcher(newRestartOfflineContactAsyncAction(contact, getHrmConfig().workerSid)), + }; +}; const connector = connect(mapStateToProps, mapDispatchToProps); export default connector(AddOfflineContactButton); diff --git a/plugin-hrm-form/src/components/OfflineContact/OfflineContactTask.tsx b/plugin-hrm-form/src/components/OfflineContact/OfflineContactTask.tsx index 4d69633f72..3c43059034 100644 --- a/plugin-hrm-form/src/components/OfflineContact/OfflineContactTask.tsx +++ b/plugin-hrm-form/src/components/OfflineContact/OfflineContactTask.tsx @@ -15,7 +15,7 @@ */ /* eslint-disable react/prop-types */ -import React from 'react'; +import React, { Dispatch } from 'react'; import { Actions, Template } from '@twilio/flex-ui'; import { connect, ConnectedProps } from 'react-redux'; @@ -35,16 +35,30 @@ import findContactByTaskSid from '../../states/contacts/findContactByTaskSid'; import getOfflineContactTaskSid from '../../states/contacts/offlineContactTaskSid'; import { getUnsavedContact } from '../../states/contacts/getUnsavedContact'; import { namespace, routingBase } from '../../states/storeNamespaces'; +import asyncDispatch from '../../states/asyncDispatch'; +import { newRestartOfflineContactAsyncAction } from '../../states/contacts/saveContact'; +import { Contact } from '../../types/types'; +import { getHrmConfig } from '../../hrmConfig'; type OwnProps = { selectedTaskSid?: string }; // eslint-disable-next-line no-use-before-define type Props = OwnProps & ConnectedProps; -const OfflineContactTask: React.FC = ({ isAddingOfflineContact, selectedTaskSid, offlineContactForms }) => { +const OfflineContactTask: React.FC = ({ + isAddingOfflineContact, + selectedTaskSid, + offlineContact, + restartContact, +}) => { if (!isAddingOfflineContact) return null; + const offlineContactForms = offlineContact?.rawJson; const onClick = async () => { + // Whilst we do this on cancel, doing it again now resets some defaults like timeofcontact + if (offlineContact) { + await restartContact(offlineContact); + } await Actions.invokeAction('SelectTask', { task: undefined }); }; @@ -83,10 +97,15 @@ const mapStateToProps = (state: RootState) => { const { savedContact, draftContact } = findContactByTaskSid(state, getOfflineContactTaskSid()) || {}; return { isAddingOfflineContact: state[namespace][routingBase].isAddingOfflineContact, - offlineContactForms: savedContact ? getUnsavedContact(savedContact, draftContact).rawJson : undefined, + offlineContact: savedContact ? getUnsavedContact(savedContact, draftContact) : undefined, }; }; -const connector = connect(mapStateToProps); +const mapDispatchToProps = (dispatch: Dispatch) => ({ + restartContact: (contact: Contact) => + asyncDispatch(dispatch)(newRestartOfflineContactAsyncAction(contact, getHrmConfig().workerSid)), +}); + +const connector = connect(mapStateToProps, mapDispatchToProps); export default connector(OfflineContactTask); diff --git a/plugin-hrm-form/src/components/TaskView.tsx b/plugin-hrm-form/src/components/TaskView.tsx index bef6e67e76..a0a36a2ce1 100644 --- a/plugin-hrm-form/src/components/TaskView.tsx +++ b/plugin-hrm-form/src/components/TaskView.tsx @@ -35,6 +35,7 @@ import { loadContactFromHrmByTaskSidAsyncAction } from '../states/contacts/saveC import { namespace } from '../states/storeNamespaces'; import { isRouteModal } from '../states/routing/types'; import { getCurrentBaseRoute } from '../states/routing/getRoute'; +import { getUnsavedContact } from '../states/contacts/getUnsavedContact'; type OwnProps = { task: CustomITask; @@ -49,7 +50,7 @@ const TaskView: React.FC = props => { shouldRecreateState, currentDefinitionVersion, task, - contact, + unsavedContact, updateHelpline, loadContactFromHrmByTaskSid, isModalOpen, @@ -66,11 +67,12 @@ const TaskView: React.FC = props => { return () => { if (isOfflineContactTask(task)) rerenderAgentDesktop(); }; - }, [task]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const contactInitialized = Boolean(contact); - const helpline = contact?.helpline; - const contactlessTask = contact?.rawJson?.contactlessTask; + const contactInitialized = Boolean(unsavedContact); + const helpline = unsavedContact?.helpline; + const contactlessTask = unsavedContact?.rawJson?.contactlessTask; // Set contactForm.helpline for all contacts on the first run. React to helpline changes for offline contacts only React.useEffect(() => { @@ -78,19 +80,19 @@ const TaskView: React.FC = props => { if (task && !isStandaloneITask(task)) { const helplineToSave = await getHelplineToSave(task, contactlessTask); if (helpline !== helplineToSave) { - updateHelpline(contact.id, helplineToSave); + updateHelpline(unsavedContact.id, helplineToSave); } } }; // Only run setHelpline if a) contactForm.helpline is not set or b) if the task is an offline contact and contactlessTask.helpline has changed - const helplineChanged = contactlessTask?.helpline && helpline !== contactlessTask.helpline; - const shouldSetHelpline = contactInitialized && (!helpline || (isOfflineContactTask(task) && helplineChanged)); + const shouldSetHelpline = + contactInitialized && (!isOfflineContactTask(task) || contactlessTask?.createdOnBehalfOf) && !helpline; if (shouldSetHelpline) { setHelpline(); } - }, [contactlessTask, contactInitialized, helpline, task, updateHelpline, contact]); + }, [contactlessTask, contactInitialized, helpline, task, updateHelpline, unsavedContact.id]); if (!currentDefinitionVersion) { return null; @@ -136,9 +138,10 @@ const mapStateToProps = ( const { task } = ownProps; const { currentDefinitionVersion } = configuration; // Check if the entry for this task exists in each reducer - const { savedContact: contact } = + const { savedContact, draftContact } = (task && Object.values(activeContacts.existingContacts).find(c => c.savedContact?.taskId)) ?? {}; - const contactFormStateExists = Boolean(contact); + const unsavedContact = getUnsavedContact(savedContact, draftContact); + const contactFormStateExists = Boolean(savedContact); const routingStateExists = Boolean(task && routing.tasks[task.taskSid]); const searchStateExists = Boolean(task && searchContacts.tasks[task.taskSid]); @@ -146,7 +149,7 @@ const mapStateToProps = ( currentDefinitionVersion && (!contactFormStateExists || !routingStateExists || !searchStateExists); return { - contact, + unsavedContact, shouldRecreateState, currentDefinitionVersion, isModalOpen: routingStateExists && isRouteModal(getCurrentBaseRoute(routing, task.taskSid)), diff --git a/plugin-hrm-form/src/components/common/forms/formGenerators.tsx b/plugin-hrm-form/src/components/common/forms/formGenerators.tsx index 32d7445855..a326d44e08 100644 --- a/plugin-hrm-form/src/components/common/forms/formGenerators.tsx +++ b/plugin-hrm-form/src/components/common/forms/formGenerators.tsx @@ -122,8 +122,13 @@ export const RequiredAsterisk = () => ( const getRules = (field: FormItemDefinition): RegisterOptions => pick(field, ['max', 'maxLength', 'min', 'minLength', 'pattern', 'required', 'validate']); -const bindCreateSelectOptions = (path: string) => (o: SelectOption) => ( - +const bindCreateSelectOptions = (path: string, initialValue: string) => (o: SelectOption) => ( + {o.label} ); @@ -485,7 +490,7 @@ export const getInputType = (parents: string[], updateCallback: () => void, cust {({ errors, register }) => { const error = get(errors, path); - const createSelectOptions = bindCreateSelectOptions(path); + const createSelectOptions = bindCreateSelectOptions(path, initialValue); return ( @@ -503,7 +508,7 @@ export const getInputType = (parents: string[], updateCallback: () => void, cust error={Boolean(error)} aria-invalid={Boolean(error)} aria-describedby={`${path}-error`} - onBlur={updateCallback} + onChange={updateCallback} ref={ref => { if (htmlElRef) { htmlElRef.current = ref; @@ -511,7 +516,6 @@ export const getInputType = (parents: string[], updateCallback: () => void, cust register(rules)(ref); }} - defaultValue={initialValue} disabled={!isEnabled} > {def.options.map(createSelectOptions)} @@ -561,7 +565,7 @@ export const getInputType = (parents: string[], updateCallback: () => void, cust const disabled = !hasOptions && !shouldInitialize; - const createSelectOptions = bindCreateSelectOptions(path); + const createSelectOptions = bindCreateSelectOptions(path, initialValue); return ( @@ -579,7 +583,7 @@ export const getInputType = (parents: string[], updateCallback: () => void, cust error={Boolean(error)} aria-invalid={Boolean(error)} aria-describedby={`${path}-error`} - onBlur={updateCallback} + onChange={updateCallback} ref={ref => { if (htmlElRef) { htmlElRef.current = ref; @@ -588,7 +592,6 @@ export const getInputType = (parents: string[], updateCallback: () => void, cust register({ validate })(ref); }} disabled={!isEnabled || disabled} - defaultValue={initialValue} > {options.map(createSelectOptions)} diff --git a/plugin-hrm-form/src/components/contact/ResourceReferralList/index.tsx b/plugin-hrm-form/src/components/contact/ResourceReferralList/index.tsx index 33a145c145..f5cc7fbf32 100644 --- a/plugin-hrm-form/src/components/contact/ResourceReferralList/index.tsx +++ b/plugin-hrm-form/src/components/contact/ResourceReferralList/index.tsx @@ -128,7 +128,9 @@ const ResourceReferralList: React.FC = ({ useEffect(() => { if (!lookedUpResource) { - loadResource(resourceReferralIdToAdd); + if (resourceReferralIdToAdd) { + loadResource(resourceReferralIdToAdd); + } } else if (lookupStatus === ReferralLookupStatus.PENDING) { if (lookedUpResource.status === ResourceLoadStatus.Error) { updateResourceReferralLookupStatus(ReferralLookupStatus.NOT_FOUND); diff --git a/plugin-hrm-form/src/components/tabbedForms/ContactlessTaskTab.tsx b/plugin-hrm-form/src/components/tabbedForms/ContactlessTaskTab.tsx index 4b264966f5..6e97757f3d 100644 --- a/plugin-hrm-form/src/components/tabbedForms/ContactlessTaskTab.tsx +++ b/plugin-hrm-form/src/components/tabbedForms/ContactlessTaskTab.tsx @@ -15,7 +15,7 @@ */ /* eslint-disable react/prop-types */ -import React from 'react'; +import React, { Dispatch } from 'react'; import { connect, ConnectedProps, useSelector } from 'react-redux'; import { FieldError, useFormContext } from 'react-hook-form'; import { isFuture } from 'date-fns'; @@ -32,6 +32,7 @@ import { splitDate, splitTime } from '../../utils/helpers'; import type { ContactRawJson, OfflineContactTask } from '../../types/types'; import { updateDraft } from '../../states/contacts/existingContacts'; import { configurationBase, contactFormsBase, namespace } from '../../states/storeNamespaces'; +import { getUnsavedContact } from '../../states/contacts/getUnsavedContact'; type OwnProps = { task: OfflineContactTask; @@ -42,18 +43,40 @@ type OwnProps = { autoFocus: boolean; }; -// eslint-disable-next-line no-use-before-define +const mapStateToProps = (state: RootState, { task }: OwnProps) => { + const { savedContact, draftContact } = + Object.values(state[namespace][contactFormsBase].existingContacts).find( + cs => cs.savedContact.taskId === task.taskSid, + ) ?? {}; + return { + counselorsList: state[namespace][configurationBase].counselors.list, + unsavedContact: getUnsavedContact(savedContact, draftContact), + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch) => { + return { + updateContactlessTaskDraft: ( + contactId: string, + contactlessTask: ContactRawJson['contactlessTask'], + helpline: string, + ) => dispatch(updateDraft(contactId, { rawJson: { contactlessTask }, helpline })), + }; +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + type Props = OwnProps & ConnectedProps; const ContactlessTaskTab: React.FC = ({ - dispatch, display, helplineInformation, definition, initialValues, counselorsList, autoFocus, - savedContact, + unsavedContact, + updateContactlessTaskDraft, }) => { const { getValues, register, setError, setValue, watch, errors } = useFormContext(); @@ -72,8 +95,8 @@ const ContactlessTaskTab: React.FC = ({ }, parentsPath: 'contactlessTask', updateCallback: () => { - const { isFutureAux, ...rest } = getValues().contactlessTask; - dispatch(updateDraft(savedContact.id, { rawJson: { contactlessTask: { ...rest } } })); + const { isFutureAux, helpline, ...contactlessTaskFields } = getValues().contactlessTask; + updateContactlessTaskDraft(unsavedContact.id, contactlessTaskFields, helpline); }, shouldFocusFirstElement: display && autoFocus, }); @@ -124,19 +147,6 @@ const ContactlessTaskTab: React.FC = ({ }; ContactlessTaskTab.displayName = 'ContactlessTaskTab'; - -const mapStateToProps = (state: RootState, { task }: OwnProps) => { - const { savedContact } = - Object.values(state[namespace][contactFormsBase].existingContacts).find( - cs => cs.savedContact.taskId === task.taskSid, - ) ?? {}; - return { - counselorsList: state[namespace][configurationBase].counselors.list, - savedContact, - }; -}; - -const connector = connect(mapStateToProps); const connected = connector(ContactlessTaskTab); export default connected; diff --git a/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx b/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx index 2d34ba3f05..cb1b6f6e13 100644 --- a/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx +++ b/plugin-hrm-form/src/components/tabbedForms/TabbedForms.tsx @@ -302,7 +302,7 @@ const TabbedForms: React.FC = ({ display={subroute === 'contactlessTask'} helplineInformation={currentDefinitionVersion.helplineInformation} definition={currentDefinitionVersion.tabbedForms.ContactlessTaskTab} - initialValues={contactlessTask} + initialValues={{ ...contactlessTask, helpline }} autoFocus={autoFocus} /> diff --git a/plugin-hrm-form/src/hrmConfig.ts b/plugin-hrm-form/src/hrmConfig.ts index f46fbaa914..e0c5a512f2 100644 --- a/plugin-hrm-form/src/hrmConfig.ts +++ b/plugin-hrm-form/src/hrmConfig.ts @@ -19,6 +19,7 @@ import * as Flex from '@twilio/flex-ui'; import { buildFormDefinitionsBaseUrlGetter, inferConfiguredFormDefinitionsBaseUrl } from './definitionVersions'; import { FeatureFlags } from './types/types'; import type { RootState } from './states'; +import { namespace } from './states/storeNamespaces'; const featureFlagEnvVarPrefix = 'REACT_FF_'; @@ -155,5 +156,5 @@ export const getAseloFeatureFlags = (): FeatureFlags => cachedConfig.featureFlag */ // eslint-disable-next-line import/no-unused-modules export const getDefinitionVersions = () => { - return (Flex.Manager.getInstance().store.getState() as RootState)['plugin-hrm-form'].configuration; + return (Flex.Manager.getInstance().store.getState() as RootState)[namespace].configuration; }; diff --git a/plugin-hrm-form/src/permissions/nz.json b/plugin-hrm-form/src/permissions/nz.json index e01a6d8914..0b804c4bc0 100644 --- a/plugin-hrm-form/src/permissions/nz.json +++ b/plugin-hrm-form/src/permissions/nz.json @@ -1,29 +1,29 @@ { - "viewCase": [["isSupervisor"], ["isCreator"]], - "closeCase": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "reopenCase": [["isSupervisor"]], - "caseStatusTransition": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "addNote": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editNote": [["isSupervisor"]], - "addReferral": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editReferral": [["isSupervisor"]], - "addHousehold": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editHousehold": [["isSupervisor"]], - "addPerpetrator": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editPerpetrator": [["isSupervisor"]], - "addIncident": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editIncident": [["isSupervisor"]], - "addDocument": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editDocument": [["isSupervisor"]], - "editCaseSummary": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editChildIsAtRisk": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "editFollowUpDate": [["isSupervisor"], ["isCreator", "isCaseOpen"]], - "viewContact": [["everyone"]], - "editContact": [["isSupervisor"], ["isOwner"]], - "viewExternalTranscript": [["everyone"]], + "viewCase": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "closeCase": [["isSupervisor"]], + "reopenCase": [["isSupervisor"]], + "caseStatusTransition": [["isSupervisor"]], + "addNote": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editNote": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "addReferral": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editReferral": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "addHousehold": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editHousehold": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "addPerpetrator": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editPerpetrator": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "addIncident": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editIncident": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "addDocument": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editDocument": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editCaseSummary": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editChildIsAtRisk": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "editFollowUpDate": [["isSupervisor"],["isCreator", "isCaseOpen"]], + "viewContact": [["everyone"]], + "editContact": [["isSupervisor"],["isOwner"]], + "viewExternalTranscript": [["everyone"]], "viewRecording": [["everyone"]], - - "viewPostSurvey": [["isSupervisor"]], + "viewPostSurvey": [["isSupervisor"]], + "viewIdentifiers": [["everyone"]] } diff --git a/plugin-hrm-form/src/services/InsightsService.ts b/plugin-hrm-form/src/services/InsightsService.ts index fc109ae192..a0ef418a0c 100644 --- a/plugin-hrm-form/src/services/InsightsService.ts +++ b/plugin-hrm-form/src/services/InsightsService.ts @@ -303,7 +303,7 @@ const applyCustomUpdate = (customUpdate: OneToManyConfigSpec): InsightsUpdateFun // If it's non data, and specs don't explicitly say to save it, ommit the update if (isNonDataCallType(rawJson.callType) && !customUpdate.saveForNonDataContacts) return {}; - const dataSource = { taskAttributes, rawJson, caseForm, savedContact }; + const dataSource = { taskAttributes, contactForm: rawJson, caseForm, savedContact }; // concatenate the values, taken from dataSource using paths (e.g. 'contactForm.childInformation.province') const value = customUpdate.paths.map(path => sanitizeInsightsValue(get(dataSource, path, ''))).join(delimiter); diff --git a/plugin-hrm-form/src/services/formSubmissionHelpers.ts b/plugin-hrm-form/src/services/formSubmissionHelpers.ts index 94efed63cd..185bbe7fa0 100644 --- a/plugin-hrm-form/src/services/formSubmissionHelpers.ts +++ b/plugin-hrm-form/src/services/formSubmissionHelpers.ts @@ -28,6 +28,8 @@ import findContactByTaskSid from '../states/contacts/findContactByTaskSid'; import { RootState } from '../states'; import * as GeneralActions from '../states/actions'; import getOfflineContactTaskSid from '../states/contacts/offlineContactTaskSid'; +import asyncDispatch from '../states/asyncDispatch'; +import { newClearContactAsyncAction, connectToCaseAsyncAction } from '../states/contacts/saveContact'; /** * Function used to manually complete a task (making sure it transitions to wrapping state first). @@ -46,17 +48,19 @@ export const completeContactTask = async (task: ITask) => { await Actions.invokeAction('CompleteTask', { sid, task }); }; -export const removeOfflineContact = () => { +export const removeOfflineContact = async () => { const offlineContactTaskSid = getOfflineContactTaskSid(); const manager = Manager.getInstance(); const contactState = findContactByTaskSid(manager.store.getState() as RootState, offlineContactTaskSid); if (contactState) { + await asyncDispatch(manager.store.dispatch)(newClearContactAsyncAction(contactState.savedContact)); + await asyncDispatch(manager.store.dispatch)(connectToCaseAsyncAction(contactState.savedContact.id, undefined)); manager.store.dispatch(GeneralActions.removeContactState(offlineContactTaskSid, contactState.savedContact.id)); } }; export const completeContactlessTask = async () => { - removeOfflineContact(); + await removeOfflineContact(); }; export const completeTask = (task: CustomITask) => diff --git a/plugin-hrm-form/src/states/contacts/saveContact.ts b/plugin-hrm-form/src/states/contacts/saveContact.ts index ec14a2840a..aa873cb81f 100644 --- a/plugin-hrm-form/src/states/contacts/saveContact.ts +++ b/plugin-hrm-form/src/states/contacts/saveContact.ts @@ -15,6 +15,7 @@ */ import { createAsyncAction, createReducer } from 'redux-promise-middleware-actions'; +import { format } from 'date-fns'; import { submitContactForm } from '../../services/formSubmissionHelpers'; import { connectToCase, createContact, getContactByTaskSid, updateContactInHrm } from '../../services/ContactService'; @@ -59,6 +60,48 @@ export const updateContactInHrmAsyncAction = createAsyncAction( }, ); +const BLANK_CONTACT_CHANGES: ContactDraftChanges = { + conversationDuration: 0, + rawJson: { + callType: '', + childInformation: {}, + callerInformation: {}, + caseInformation: {}, + categories: {}, + contactlessTask: { + channel: null, + createdOnBehalfOf: null, + date: null, + time: null, + }, + }, +}; + +export const newClearContactAsyncAction = (contact: Contact) => + updateContactInHrmAsyncAction(contact, { + ...BLANK_CONTACT_CHANGES, + timeOfContact: new Date().toISOString(), + }); + +export const newRestartOfflineContactAsyncAction = (contact: Contact, createdOnBehalfOf: string) => { + const now = new Date(); + const time = format(now, 'HH:mm'); + const date = format(now, 'yyyy-MM-dd'); + return updateContactInHrmAsyncAction(contact, { + ...BLANK_CONTACT_CHANGES, + timeOfContact: now.toISOString(), + rawJson: { + ...BLANK_CONTACT_CHANGES.rawJson, + contactlessTask: { + channel: null, + createdOnBehalfOf, + date, + time, + }, + }, + }); +}; + // TODO: Update connectedContacts on case in redux state export const connectToCaseAsyncAction = createAsyncAction( CONNECT_TO_CASE, diff --git a/plugin-hrm-form/src/states/routing/reducer.ts b/plugin-hrm-form/src/states/routing/reducer.ts index 8ce701151a..3ab025c9b0 100644 --- a/plugin-hrm-form/src/states/routing/reducer.ts +++ b/plugin-hrm-form/src/states/routing/reducer.ts @@ -91,7 +91,9 @@ const contactUpdatingReducer = (state: RoutingState, action: ContactUpdatingActi : [initialEntry], }, isAddingOfflineContact: - taskId === getOfflineContactTaskSid() ? true : stateWithoutPreviousContact.isAddingOfflineContact, + taskId === getOfflineContactTaskSid() && contact?.rawJson?.contactlessTask?.createdOnBehalfOf + ? true + : stateWithoutPreviousContact.isAddingOfflineContact, }; }; diff --git a/twilio-iac/helplines/mt/configs/service-configuration/staging.json b/twilio-iac/helplines/mt/configs/service-configuration/staging.json index 6323e8e0b7..b737b76b3d 100644 --- a/twilio-iac/helplines/mt/configs/service-configuration/staging.json +++ b/twilio-iac/helplines/mt/configs/service-configuration/staging.json @@ -1,7 +1,8 @@ { "attributes": { "feature_flags": { - "enable_emoji_picker": false + "enable_manual_pulling": false, + "enable_twilio_transcripts": false }, "form_definitions_base_url": "https://assets-staging.tl.techmatters.org/form-definitions/", "helplineLanguage": "" diff --git a/twilio-iac/helplines/templates/studio-flows/messaging-no-chatbot-operating-hours.tftpl b/twilio-iac/helplines/templates/studio-flows/messaging-no-chatbot-operating-hours.tftpl index 6c0c510bcb..90ff0acd1c 100644 --- a/twilio-iac/helplines/templates/studio-flows/messaging-no-chatbot-operating-hours.tftpl +++ b/twilio-iac/helplines/templates/studio-flows/messaging-no-chatbot-operating-hours.tftpl @@ -96,12 +96,12 @@ ${ "event": "match", "conditions": [ { - "friendly_name": "If value equal_to closed", + "friendly_name": "If matches_any_of closed,holiday", "arguments": [ "{{widgets.check_operating_hours.parsed.status}}" ], - "type": "equal_to", - "value": "closed" + "type": "matches_any_of", + "value": "closed,holiday" } ] } diff --git a/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours.tftpl b/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours.tftpl index 142d2c2891..294184701a 100644 --- a/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours.tftpl +++ b/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours.tftpl @@ -138,16 +138,16 @@ ${ ] }, { - "next": "say_closed", + "next": "send_closed", "event": "match", "conditions": [ { - "friendly_name": "If value equal_to closed", + "friendly_name": "If matches_any_of closed,holiday", "arguments": [ "{{widgets.check_operating_hours.parsed.status}}" ], - "type": "equal_to", - "value": "closed" + "type": "matches_any_of", + "value": "closed,holiday" } ] } diff --git a/twilio-iac/terraform-modules/stages/provision/main.tf b/twilio-iac/terraform-modules/stages/provision/main.tf index dbaa59282c..a4a014f64f 100644 --- a/twilio-iac/terraform-modules/stages/provision/main.tf +++ b/twilio-iac/terraform-modules/stages/provision/main.tf @@ -74,7 +74,7 @@ module "aws" { flex_chat_service_sid = module.services.flex_chat_service_sid flex_proxy_service_sid = module.services.flex_proxy_service_sid # TODO: manually delete this resource from SSM after migration - # post_survey_bot_sid = module.chatbots.post_survey_bot_sid + post_survey_bot_sid = "deleted" # The serverless deploy action assumes that this paramater exists, so in order not to break it # we need to add a non-valid workflow sid. survey_workflow_sid = try(module.taskRouter.workflow_sids.survey, "NOTVALIDWORKFLOWSID")