diff --git a/plugin-hrm-form/src/___tests__/components/case/AddEditCaseItem.test.tsx b/plugin-hrm-form/src/___tests__/components/case/AddEditCaseItem.test.tsx index 840d17b07a..44675bd083 100644 --- a/plugin-hrm-form/src/___tests__/components/case/AddEditCaseItem.test.tsx +++ b/plugin-hrm-form/src/___tests__/components/case/AddEditCaseItem.test.tsx @@ -120,7 +120,6 @@ const hrmState: Partial = { }, }, [contactFormsBase]: { - editingContact: false, isCallTypeCaller: false, contactDetails: { contactSearch: { detailsExpanded: {} }, caseDetails: { detailsExpanded: {} } }, existingContacts: { diff --git a/plugin-hrm-form/src/___tests__/components/case/ViewContact.test.tsx b/plugin-hrm-form/src/___tests__/components/case/ViewContact.test.tsx index bab4f6b8ce..39267689df 100644 --- a/plugin-hrm-form/src/___tests__/components/case/ViewContact.test.tsx +++ b/plugin-hrm-form/src/___tests__/components/case/ViewContact.test.tsx @@ -174,7 +174,6 @@ describe('View Contact', () => { }, }, activeContacts: { - editingContact: false, isCallTypeCaller: false, existingContacts: { TEST_ID: { diff --git a/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts b/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts new file mode 100644 index 0000000000..c42f730d77 --- /dev/null +++ b/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts @@ -0,0 +1,307 @@ +/** + * 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 each from 'jest-each'; + +import { AppRoutes, CaseItemAction, RoutingState } from '../../../states/routing/types'; +import { standaloneTaskSid } from '../../../types/types'; +import { initialState } from '../../../states/routing/reducer'; +import { + getCurrentBaseRoute, + getCurrentTopmostRouteForTask, + getCurrentTopmostRouteStackForTask, +} from '../../../states/routing/getRoute'; + +type TestCase = { + state: RoutingState; + expected: T; + description: string; +}; + +const stateWithRouteStack = (baseRoutes: AppRoutes[]): RoutingState => ({ + tasks: { + 1: baseRoutes, + [standaloneTaskSid]: initialState.tasks[standaloneTaskSid], + }, + isAddingOfflineContact: false, +}); + +describe('getCurrentTopmostRouteStackForTask', () => { + const testCases: TestCase[] = [ + { + description: 'No modals open - should return the base route a task', + state: stateWithRouteStack([ + { route: 'case-list', subroute: 'case-list' }, + { route: 'case', subroute: 'home' }, + ]), + expected: [ + { route: 'case-list', subroute: 'case-list' }, + { route: 'case', subroute: 'home' }, + ], + }, + { + description: 'Modal open - should return the modal route', + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ]), + expected: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + { + description: 'Stacked modal open - should return the top modal route', + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + activeModal: [ + { route: 'case', subroute: 'household', action: CaseItemAction.View, id: 'x' }, + { route: 'case', subroute: 'household', action: CaseItemAction.Edit, id: 'x' }, + ], + }, + ], + }, + ]), + expected: [ + { route: 'case', subroute: 'household', action: CaseItemAction.View, id: 'x' }, + { route: 'case', subroute: 'household', action: CaseItemAction.Edit, id: 'x' }, + ], + }, + { + description: "Modal open in history - shouldn't really happen, ignore it", + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + }, + ], + }, + { route: 'search', subroute: 'form' }, + ]), + expected: [ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + }, + ], + }, + { route: 'search', subroute: 'form' }, + ], + }, + ]; + + each(testCases).test('$description', ({ state, expected }) => { + expect(getCurrentTopmostRouteStackForTask(state, '1')).toEqual(expected); + }); +}); + +describe('getCurrentTopmostRouteForTask', () => { + const testCases: TestCase[] = [ + { + description: 'No modals open - should return the current base route a task', + state: stateWithRouteStack([ + { route: 'case-list', subroute: 'case-list' }, + { route: 'case', subroute: 'home' }, + ]), + expected: { route: 'case', subroute: 'home' }, + }, + { + description: 'Modal open - should return the modal route', + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ]), + expected: { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + }, + { + description: 'Stacked modal open - should return the top modal route', + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + activeModal: [ + { route: 'case', subroute: 'household', action: CaseItemAction.View, id: 'x' }, + { route: 'case', subroute: 'household', action: CaseItemAction.Edit, id: 'x' }, + ], + }, + ], + }, + ]), + expected: { route: 'case', subroute: 'household', action: CaseItemAction.Edit, id: 'x' }, + }, + { + description: "Modal open in history - shouldn't really happen, ignore it", + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + }, + ], + }, + { route: 'search', subroute: 'form' }, + ]), + expected: { route: 'search', subroute: 'form' }, + }, + { + description: "Top modal stack is empty - shouldn't really happen, return undefined", + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + activeModal: [], + }, + ], + }, + ]), + expected: undefined, + }, + ]; + + each(testCases).test('$description', ({ state, expected }) => { + expect(getCurrentTopmostRouteForTask(state, '1')).toEqual(expected); + }); +}); + +describe('getCurrentBaseRouteForTask', () => { + const testCases: TestCase[] = [ + { + description: 'No modals open - should return the current base route a task', + state: stateWithRouteStack([ + { route: 'case-list', subroute: 'case-list' }, + { route: 'case', subroute: 'home' }, + ]), + expected: { route: 'case', subroute: 'home' }, + }, + { + description: 'Modal open - should still return the current base route', + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ]), + expected: { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + }, + { + description: 'Stacked modal open - should still return the current base route', + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + activeModal: [ + { route: 'case', subroute: 'household', action: CaseItemAction.View, id: 'x' }, + { route: 'case', subroute: 'household', action: CaseItemAction.Edit, id: 'x' }, + ], + }, + ], + }, + ]), + expected: { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + activeModal: [ + { route: 'case', subroute: 'household', action: CaseItemAction.View, id: 'x' }, + { route: 'case', subroute: 'household', action: CaseItemAction.Edit, id: 'x' }, + ], + }, + ], + }, + }, + { + description: "Modal open in history - shouldn't really happen, ignore it", + state: stateWithRouteStack([ + { + route: 'case-list', + subroute: 'case-list', + activeModal: [ + { + route: 'case', + subroute: 'home', + }, + ], + }, + { route: 'search', subroute: 'form' }, + ]), + expected: { route: 'search', subroute: 'form' }, + }, + { + description: "Bsse modal stack is empty - shouldn't really happen, return undefined", + state: stateWithRouteStack([]), + expected: undefined, + }, + ]; + + each(testCases).test('$description', ({ state, expected }) => { + expect(getCurrentBaseRoute(state, '1')).toEqual(expected); + }); +}); 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 f0749a1ccf..175653d3d1 100644 --- a/plugin-hrm-form/src/___tests__/states/routing/reducer.test.ts +++ b/plugin-hrm-form/src/___tests__/states/routing/reducer.test.ts @@ -15,6 +15,7 @@ */ import { DefinitionVersion, DefinitionVersionId, loadDefinition, useFetchDefinitions } from 'hrm-form-definitions'; +import each from 'jest-each'; import { mockGetDefinitionsResponse, mockPartialConfiguration } from '../../mockGetConfig'; import { getDefinitionVersions } from '../../../hrmConfig'; @@ -27,7 +28,13 @@ import { CREATE_CONTACT_ACTION_FULFILLED, LOAD_CONTACT_FROM_HRM_BY_TASK_ID_ACTION_FULFILLED, } from '../../../states/contacts/types'; -import { ChangeRouteMode, RoutingState } from '../../../states/routing/types'; +import { + AppRoutes, + CaseItemAction, + ChangeRouteMode, + RoutingActionType, + RoutingState, +} from '../../../states/routing/types'; // eslint-disable-next-line react-hooks/rules-of-hooks const { mockFetchImplementation, mockReset, buildBaseURL } = useFetchDefinitions(); @@ -89,33 +96,417 @@ describe('test reducer (specific actions)', () => { expect(result).toStrictEqual(expected); }); - test('should handle CHANGE_ROUTE by adding new route to stack if replace is not set', async () => { - const expected = { - tasks: { - task1: [{ route: 'tabbed-forms', subroute: 'childInformation' }, { route: 'tabbed-forms' }], - [standaloneTaskSid]: initialState.tasks[standaloneTaskSid], - }, - isAddingOfflineContact: false, - }; + type TestCase = { + startingState: RoutingState; + expected: RoutingState; + action: RoutingActionType; + description: string; + }; - const result = reduce(stateWithTask, actions.changeRoute({ route: 'tabbed-forms' }, task.taskSid)); + const genericRoutingTest = async ({ startingState, expected, action }: TestCase) => { + const result = reduce(startingState, action); expect(result).toStrictEqual(expected); + }; + + const stateWithRouteStack = (baseRoutes: AppRoutes[]): RoutingState => ({ + tasks: { + task1: baseRoutes, + [standaloneTaskSid]: initialState.tasks[standaloneTaskSid], + }, + isAddingOfflineContact: false, }); - test('should handle CHANGE_ROUTE by replacing topmost route with new route to stack if replace is set', async () => { - const expected = { - tasks: { - task1: [{ route: 'tabbed-forms' }], - [standaloneTaskSid]: initialState.tasks[standaloneTaskSid], + describe('CHANGE_ROUTE action', () => { + const tests = (stateGenerator: (routes: AppRoutes[]) => RoutingState, routeDescription: string) => [ + { + startingState: stateGenerator([ + { route: 'tabbed-forms', subroute: 'childInformation' }, + { route: 'case', subroute: 'home' }, + ]), + expected: stateGenerator([ + { route: 'tabbed-forms', subroute: 'childInformation' }, + { route: 'case', subroute: 'home' }, + { route: 'tabbed-forms' }, + ]), + action: actions.changeRoute({ route: 'tabbed-forms' }, task.taskSid), + description: `should add new route to the ${routeDescription} stack if mode is Push`, }, - isAddingOfflineContact: false, - }; + { + startingState: stateGenerator([ + { route: 'tabbed-forms', subroute: 'childInformation' }, + { route: 'case', subroute: 'home' }, + ]), + expected: stateGenerator([{ route: 'tabbed-forms', subroute: 'childInformation' }, { route: 'tabbed-forms' }]), + action: actions.changeRoute({ route: 'tabbed-forms' }, task.taskSid, ChangeRouteMode.Replace), + description: `should replace the most recent ${routeDescription} route with new route to stack if mode is Replace`, + }, + { + startingState: stateGenerator([ + { route: 'tabbed-forms', subroute: 'childInformation' }, + { route: 'case', subroute: 'home' }, + ]), + expected: stateGenerator([{ route: 'tabbed-forms' }]), + action: actions.changeRoute({ route: 'tabbed-forms' }, task.taskSid, ChangeRouteMode.Reset), + description: `should replace the whole ${routeDescription} route with a new stack containing the new route as the only item if mode is Reset`, + }, + ]; + describe('Not currently in a modal', () => { + each([...tests(stateWithRouteStack, 'base')]).test('$description', genericRoutingTest); + }); - const result = reduce( - stateWithTask, - actions.changeRoute({ route: 'tabbed-forms' }, task.taskSid, ChangeRouteMode.Replace), - ); - expect(result).toStrictEqual(expected); + describe('When modal is open', () => { + const stateWithModal = (modalRoutes: AppRoutes[]): RoutingState => ({ + tasks: { + task1: [{ route: 'tabbed-forms', subroute: 'childInformation', activeModal: modalRoutes }], + [standaloneTaskSid]: initialState.tasks[standaloneTaskSid], + }, + isAddingOfflineContact: false, + }); + + each([...tests(stateWithModal, 'modal')]).test('$description', genericRoutingTest); + }); + }); + + describe('OPEN_MODAL action', () => { + const tests: TestCase[] = [ + { + description: + 'Current route not in a modal - should create activeModal on latest route in base stack with the provided route as the only item', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + ]), + action: actions.newOpenModalAction({ route: 'case', subroute: 'home' }, task.taskSid), + }, + { + description: + "Current route in a modal - should create activeModal on latest route in the top modal's stack with the provided route as the only item", + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { route: 'search', subroute: 'case-results' }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { route: 'search', subroute: 'case-results', activeModal: [{ route: 'case', subroute: 'home' }] }, + ], + }, + ]), + action: actions.newOpenModalAction({ route: 'case', subroute: 'home' }, task.taskSid), + }, + { + description: + "Current route has active modal on a previous route (shouldn't happen) - should still create activeModal on latest route in base stack", + startingState: stateWithRouteStack([ + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + expected: stateWithRouteStack([ + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + ]), + action: actions.newOpenModalAction({ route: 'case', subroute: 'home' }, task.taskSid), + }, + ]; + + each(tests).test('$description', genericRoutingTest); + }); + + describe('GO_BACK action', () => { + const tests: TestCase[] = [ + { + description: 'Current route not in a modal - should pop the latest route from the base stack', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + expected: stateWithRouteStack([{ route: 'select-call-type' }]), + action: actions.newGoBackAction(task.taskSid), + }, + { + description: 'Current route in a modal - should pop the latest route from the modal stack', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { route: 'search', subroute: 'case-results' }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [{ route: 'search', subroute: 'form' }], + }, + ]), + action: actions.newGoBackAction(task.taskSid), + }, + { + description: 'Current route in a stack of modals - should pop the latest route from the top modal stack', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [{ route: 'case', subroute: 'home' }], + }, + ], + }, + ]), + action: actions.newGoBackAction(task.taskSid), + }, + { + description: + "Current route has active modal on a previous route (shouldn't happen) - should still pop the latest route from the base stack", + startingState: stateWithRouteStack([ + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + expected: stateWithRouteStack([ + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + ]), + action: actions.newGoBackAction(task.taskSid), + }, + { + description: 'Current route is the only route in the base stack - should do nothing', + startingState: stateWithRouteStack([{ route: 'select-call-type' }]), + expected: stateWithRouteStack([{ route: 'select-call-type' }]), + action: actions.newGoBackAction(task.taskSid), + }, + { + description: 'Current route is the only route in the modal stack - should close modal', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation', activeModal: [{ route: 'case', subroute: 'home' }] }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + action: actions.newGoBackAction(task.taskSid), + }, + ]; + each(tests).test('$description', genericRoutingTest); + }); + + describe('CLOSE_MODAL action', () => { + const tests: TestCase[] = [ + { + description: 'Current route not in a modal - should do nothing', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + action: actions.newCloseModalAction(task.taskSid), + }, + { + description: 'Current route in a modal - should close the modal', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { route: 'search', subroute: 'case-results' }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { route: 'tabbed-forms', subroute: 'childInformation' }, + ]), + action: actions.newCloseModalAction(task.taskSid), + }, + { + description: 'Current route in a stack of modals - should close the top modal', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { route: 'search', subroute: 'case-results' }, + ], + }, + ]), + action: actions.newCloseModalAction(task.taskSid), + }, + { + description: + 'Current route in a stack of modals & topRoute set - should close all modals above the one where topRoute is the latest route', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + }, + ]), + action: actions.newCloseModalAction(task.taskSid, 'tabbed-forms'), + }, + { + description: 'Current route in a stack of modals & topRoute set to current top modal route - should do nothing', + startingState: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ], + }, + ]), + expected: stateWithRouteStack([ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ], + }, + ]), + action: actions.newCloseModalAction(task.taskSid, 'case'), + }, + { + // This behaviour is probably not the most logical behaviour, but it simplifies the algorithm, + // so until we have a use case for changing it, this is what it does + description: + 'topRoute is the latest route in more than one layer - should close down to the lowest matching route', + startingState: stateWithRouteStack([ + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'select-call-type' }, + { + route: 'tabbed-forms', + subroute: 'childInformation', + activeModal: [ + { route: 'search', subroute: 'form' }, + { + route: 'search', + subroute: 'case-results', + activeModal: [ + { route: 'case', subroute: 'home' }, + { route: 'case', subroute: 'caseSummary', action: CaseItemAction.View, id: '' }, + ], + }, + ], + }, + ], + }, + ]), + expected: stateWithRouteStack([ + { + route: 'tabbed-forms', + subroute: 'childInformation', + }, + ]), + action: actions.newCloseModalAction(task.taskSid, 'tabbed-forms'), + }, + ]; + each(tests).test('$description', genericRoutingTest); }); test('should handle REMOVE_CONTACT_STATE', async () => { diff --git a/plugin-hrm-form/src/components/CSAMReport/CSAMReport.tsx b/plugin-hrm-form/src/components/CSAMReport/CSAMReport.tsx index 6a45aef707..df855d427c 100644 --- a/plugin-hrm-form/src/components/CSAMReport/CSAMReport.tsx +++ b/plugin-hrm-form/src/components/CSAMReport/CSAMReport.tsx @@ -24,7 +24,6 @@ import CSAMReportCounsellorForm from './CSAMReportCounsellorForm'; import { CenterContent, CSAMReportContainer, CSAMReportLayout } from '../../styles/CSAMReport'; import { RootState } from '../../states'; import { CSAMPage, CSAMReportApi } from './csamReportApi'; -import * as t from '../../states/contacts/actions'; import { isChildTaskEntry, isCounsellorTaskEntry } from '../../states/csam-report/types'; import CSAMReportTypePickerForm from './CSAMReportTypePicker'; import CSAMReportChildForm from './CSAMReportChildForm'; @@ -50,8 +49,6 @@ const mapDispatchToProps = (dispatch: Dispatch, { api }: OwnProps) => { exit: api.exitActionDispatcher(dispatch), addCSAMReportEntry: api.addReportDispatcher(dispatch), pickReportType: api.pickReportTypeDispatcher(dispatch), - setEditPageOpen: () => dispatch(t.setEditContactPageOpen()), - setEditPageClosed: () => dispatch(t.setEditContactPageClosed()), }; }; @@ -70,8 +67,6 @@ export const CSAMReportScreen: React.FC = ({ currentPage, counselorsHash, api, - setEditPageClosed, - setEditPageOpen, pickReportType, }) => { const methods = useForm({ reValidateMode: 'onChange' }); @@ -80,14 +75,6 @@ export const CSAMReportScreen: React.FC = ({ const strings = getTemplateStrings(); const currentCounselor = counselorsHash[workerSid]; - React.useEffect(() => { - setEditPageOpen(); - return () => { - setEditPageClosed(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - if (!currentPage) return null; const onValid = async () => { diff --git a/plugin-hrm-form/src/components/CustomCRMContainer.tsx b/plugin-hrm-form/src/components/CustomCRMContainer.tsx index d1bcc3f0ff..9b0ef0ee0d 100644 --- a/plugin-hrm-form/src/components/CustomCRMContainer.tsx +++ b/plugin-hrm-form/src/components/CustomCRMContainer.tsx @@ -18,6 +18,7 @@ import React, { Dispatch, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { ITask, withTaskContext } from '@twilio/flex-ui'; +import _ from 'lodash'; import { DefinitionVersion } from 'hrm-form-definitions'; import TaskView from './TaskView'; @@ -28,6 +29,7 @@ import { RootState } from '../states'; import { OfflineContactTask } from '../types/types'; import getOfflineContactTaskSid from '../states/contacts/offlineContactTaskSid'; import { namespace } from '../states/storeNamespaces'; +import { getUnsavedContact } from '../states/contacts/getUnsavedContact'; import asyncDispatch from '../states/asyncDispatch'; import { createContactAsyncAction } from '../states/contacts/saveContact'; import { getHrmConfig } from '../hrmConfig'; @@ -40,10 +42,13 @@ type OwnProps = { // eslint-disable-next-line no-use-before-define type Props = OwnProps & ConnectedProps; +let handleUnloadRef = null; + const CustomCRMContainer: React.FC = ({ selectedTaskSid, isAddingOfflineContact, task, + hasUnsavedChanges, populateCounselorList, currentOfflineContact, definitionVersion, @@ -69,6 +74,26 @@ const CustomCRMContainer: React.FC = ({ } }, [currentOfflineContact, definitionVersion, loadOrCreateDraftOfflineContact]); + useEffect(() => { + if (handleUnloadRef) { + window.removeEventListener('beforeunload', handleUnloadRef); + } + handleUnloadRef = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = 'something'; + return 'something'; + } + return undefined; + }; + window.addEventListener('beforeunload', handleUnloadRef); + return () => { + if (handleUnloadRef) { + window.removeEventListener('beforeunload', handleUnloadRef); + } + }; + }, [hasUnsavedChanges]); + const offlineContactTask: OfflineContactTask = { taskSid: getOfflineContactTaskSid(), channelType: 'default', @@ -90,18 +115,29 @@ const CustomCRMContainer: React.FC = ({ CustomCRMContainer.displayName = 'CustomCRMContainer'; -const mapStateToProps = ({ [namespace]: { routing, activeContacts, configuration }, flex }: RootState) => { +const mapStateToProps = ({ + [namespace]: { routing, activeContacts, configuration, connectedCase }, + flex, +}: RootState) => { const { selectedTaskSid } = flex.view; const { isAddingOfflineContact } = routing; const currentOfflineContact = Object.values(activeContacts.existingContacts).find( contact => contact.savedContact.taskId === getOfflineContactTaskSid(), ); - + const hasUnsavedChanges = + Object.values(activeContacts.existingContacts).some( + ({ savedContact, draftContact }) => !_.isEqual(savedContact, getUnsavedContact(savedContact, draftContact)), + ) || + Object.values(connectedCase.tasks).some( + ({ caseWorkingCopy }) => + caseWorkingCopy.caseSummary || Object.values(caseWorkingCopy.sections).some(section => section), + ); return { selectedTaskSid, isAddingOfflineContact, currentOfflineContact, definitionVersion: configuration.currentDefinitionVersion, + hasUnsavedChanges, }; }; diff --git a/plugin-hrm-form/src/components/PreviousContactsBanner.tsx b/plugin-hrm-form/src/components/PreviousContactsBanner.tsx index dbdc9b2d95..b620850201 100644 --- a/plugin-hrm-form/src/components/PreviousContactsBanner.tsx +++ b/plugin-hrm-form/src/components/PreviousContactsBanner.tsx @@ -35,8 +35,6 @@ import { getFormattedNumberFromTask, getNumberFromTask, getContactValueTemplate import { getPermissionsForViewingIdentifiers, PermissionActions } from '../permissions'; import { CustomITask, isTwilioTask } from '../types/types'; import { namespace } from '../states/storeNamespaces'; -import { isRouteModal } from '../states/routing/types'; -import { getCurrentBaseRoute } from '../states/routing/getRoute'; type OwnProps = { task: CustomITask; @@ -52,7 +50,6 @@ const PreviousContactsBanner: React.FC = ({ searchContacts, searchCases, openContactSearchResults, - modalOpen, }) => { const { canView } = getPermissionsForViewingIdentifiers(); const maskIdentifiers = !canView(PermissionActions.VIEW_IDENTIFIERS); @@ -98,7 +95,7 @@ const PreviousContactsBanner: React.FC = ({ } return ( -
+
{/* eslint-disable-next-line prettier/prettier */}
@@ -155,12 +152,10 @@ const mapStateToProps = (
 ) => {
   const taskSearchState = searchContacts.tasks[taskSid];
   const { counselors } = configuration;
-  const modalOpen = activeContacts.editingContact || isRouteModal(getCurrentBaseRoute(routing, taskSid));
 
   return {
     previousContacts: taskSearchState.previousContacts,
     counselorsHash: counselors.hash,
-    modalOpen,
   };
 };
 
diff --git a/plugin-hrm-form/src/components/search/ContactDetails/index.tsx b/plugin-hrm-form/src/components/search/ContactDetails/index.tsx
index 714bca58f1..2715cdb1df 100644
--- a/plugin-hrm-form/src/components/search/ContactDetails/index.tsx
+++ b/plugin-hrm-form/src/components/search/ContactDetails/index.tsx
@@ -38,9 +38,9 @@ type OwnProps = {
 };
 
 const mapStateToProps = ({ [namespace]: { activeContacts, configuration } }: RootState, { contact }: OwnProps) => {
-  const { isCallTypeCaller, editingContact: editContactFormOpen } = activeContacts;
+  const { isCallTypeCaller } = activeContacts;
   const definitionVersion = configuration.definitionVersions[contact.rawJson.definitionVersion];
-  return { editContactFormOpen, isCallTypeCaller, definitionVersion };
+  return { isCallTypeCaller, definitionVersion };
 };
 const mapDispatchToProps = {
   loadContactIntoState: loadContact,
diff --git a/plugin-hrm-form/src/components/search/index.tsx b/plugin-hrm-form/src/components/search/index.tsx
index 804ac6d398..a958ea93f5 100644
--- a/plugin-hrm-form/src/components/search/index.tsx
+++ b/plugin-hrm-form/src/components/search/index.tsx
@@ -201,7 +201,6 @@ const Search: React.FC = ({
   renderSearchPages.displayName = 'SearchPage';
 
   return (
-    // TODO: Needs converting to a div and the className={editContactFormOpen ? 'editingContact' : ''} adding, but that messes up the CSS
     <>
       {renderMockDialog()}
       {renderSearchPages()}
@@ -222,7 +221,6 @@ const mapStateToProps = (
   const taskId = task.taskSid;
   const taskSearchState = searchContacts.tasks[taskId];
   const isStandaloneSearch = taskId === standaloneTaskSid;
-  const editContactFormOpen = activeContacts.editingContact;
   const currentRoute = getCurrentTopmostRouteForTask(routing, taskId);
 
   return {
@@ -232,7 +230,6 @@ const mapStateToProps = (
     searchContactsResults: taskSearchState.searchContactsResult,
     searchCasesResults: taskSearchState.searchCasesResult,
     showActionIcons: !isStandaloneSearch,
-    editContactFormOpen,
     routing: currentRoute,
   };
 };
diff --git a/plugin-hrm-form/src/states/contacts/actions.ts b/plugin-hrm-form/src/states/contacts/actions.ts
index d7dca8a1dc..f57cfa68c1 100644
--- a/plugin-hrm-form/src/states/contacts/actions.ts
+++ b/plugin-hrm-form/src/states/contacts/actions.ts
@@ -47,16 +47,6 @@ export const restoreEntireContact = (contact: ContactState): t.ContactsActionTyp
   contact,
 });
 
-export const setEditContactPageOpen = (): t.ContactsActionType => ({
-  type: t.SET_EDITING_CONTACT,
-  editing: true,
-});
-
-export const setEditContactPageClosed = (): t.ContactsActionType => ({
-  type: t.SET_EDITING_CONTACT,
-  editing: false,
-});
-
 export const setCallType = (isCallTypeCaller: boolean): t.ContactsActionType => ({
   type: t.SET_CALL_TYPE,
   isCallTypeCaller,
diff --git a/plugin-hrm-form/src/states/contacts/reducer.ts b/plugin-hrm-form/src/states/contacts/reducer.ts
index 087272dfce..59d105baab 100644
--- a/plugin-hrm-form/src/states/contacts/reducer.ts
+++ b/plugin-hrm-form/src/states/contacts/reducer.ts
@@ -71,7 +71,6 @@ export const initialState: ContactsState = {
     [DetailsContext.CASE_DETAILS]: { detailsExpanded: {} },
     [DetailsContext.CONTACT_SEARCH]: { detailsExpanded: {} },
   },
-  editingContact: false,
   isCallTypeCaller: false,
 };
 
@@ -190,9 +189,6 @@ export function reduce(
     case t.SET_CALL_TYPE: {
       return { ...state, isCallTypeCaller: action.isCallTypeCaller };
     }
-    case t.SET_EDITING_CONTACT: {
-      return { ...state, editingContact: action.editing };
-    }
     case UPDATE_CONTACT_ACTION_FULFILLED:
     case CREATE_CONTACT_ACTION_FULFILLED:
     case LOAD_CONTACT_FROM_HRM_BY_TASK_ID_ACTION_FULFILLED: {
diff --git a/plugin-hrm-form/src/states/contacts/types.ts b/plugin-hrm-form/src/states/contacts/types.ts
index f20a227726..76ab6bdd87 100644
--- a/plugin-hrm-form/src/states/contacts/types.ts
+++ b/plugin-hrm-form/src/states/contacts/types.ts
@@ -58,7 +58,6 @@ export type ContactMetadata = {
 export type ContactsState = {
   existingContacts: ExistingContactsState;
   contactDetails: ContactDetailsState;
-  editingContact: boolean;
   isCallTypeCaller: boolean;
 };
 
@@ -86,11 +85,6 @@ type RestoreEntireFormAction = {
   contact: ContactState;
 };
 
-type SetEditingContact = {
-  type: typeof SET_EDITING_CONTACT;
-  editing: boolean;
-};
-
 type CheckButtonDataAction = {
   type: typeof SET_CALL_TYPE;
   isCallTypeCaller: boolean;
@@ -100,7 +94,6 @@ export type ContactsActionType =
   | SaveEndMillisAction
   | PrePopulateFormAction
   | RestoreEntireFormAction
-  | SetEditingContact
   | CheckButtonDataAction;
 
 export type ContactUpdatingAction = {
diff --git a/plugin-hrm-form/src/states/routing/reducer.ts b/plugin-hrm-form/src/states/routing/reducer.ts
index 3f35228639..e8d44bb899 100644
--- a/plugin-hrm-form/src/states/routing/reducer.ts
+++ b/plugin-hrm-form/src/states/routing/reducer.ts
@@ -147,6 +147,13 @@ const popTopmostRoute = (baseRouteStack: AppRoutes[]): AppRoutes[] => {
         { ...currentRoute, activeModal: popTopmostRoute(currentRoute.activeModal) },
       ];
     }
+    // Don't empty the base route stack, this will result in Bad Things (TM)
+    if (baseRouteStack.length <= 1 && !isRouteWithModalSupport(currentRoute)) {
+      console.warn(
+        `Tried to go back in the base route stack but there was ${baseRouteStack.length} routes in the stack so doing nothing. This could indicate a routing logic issue in the components.`,
+      );
+      return baseRouteStack;
+    }
     return baseRouteStack.slice(0, -1);
   }
   return baseRouteStack;
@@ -165,9 +172,13 @@ const closeTopModal = (
       !currentRoute.activeModal ||
       (topRoute && parent?.route === topRoute)
     ) {
-      // This is the top of the modal stack - if it has a parent, return undefined so the caller removes it
-      // Otherwise it's the base route, so just return it as is
-      return parent ? undefined : routeStack;
+      // If no parent is set, it must be the base route stack, so don't remove it
+      // Otherwise, if the topRoute is set and doesn't match the parent, the topRoute mustn't be in the stack, so don't remove anything
+      if (!parent || (topRoute && parent?.route !== topRoute)) {
+        return routeStack;
+      }
+      // Otherwise this is the top of the modal stack or a route matching the specified topRoute - return undefined so the caller removes it
+      return undefined;
     }
     const nextStack = closeTopModal(currentRoute.activeModal, topRoute, currentRoute);
     if (nextStack) {
diff --git a/plugin-hrm-form/src/states/routing/types.ts b/plugin-hrm-form/src/states/routing/types.ts
index 66fa826efa..d799d8e562 100644
--- a/plugin-hrm-form/src/states/routing/types.ts
+++ b/plugin-hrm-form/src/states/routing/types.ts
@@ -39,7 +39,7 @@ export type TabbedFormRoute = {
   autoFocus?: boolean;
 } & RouteWithModalSupport;
 
-export type SearchRoute = {
+export type SearchRoute = RouteWithModalSupport & {
   route: 'search';
   subroute: 'form' | 'case-results' | 'contact-results';
 };