From e0fdccddd4e017b032d990960c523f11254f1c53 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 24 Oct 2023 15:16:10 +0100 Subject: [PATCH 1/6] Remove redundant 'editingContact' redux state & associated paraphernalia --- .../components/case/AddEditCaseItem.test.tsx | 1 - .../___tests__/components/case/ViewContact.test.tsx | 1 - .../src/components/CSAMReport/CSAMReport.tsx | 13 ------------- .../src/components/PreviousContactsBanner.tsx | 7 +------ plugin-hrm-form/src/states/contacts/actions.ts | 10 ---------- plugin-hrm-form/src/states/contacts/reducer.ts | 4 ---- plugin-hrm-form/src/states/contacts/types.ts | 7 ------- 7 files changed, 1 insertion(+), 42 deletions(-) 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/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/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/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 b6fd1e4902..ae7bf45ed6 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 = {

From 5e5c7ff30a64381a2926379b0095e53cf5f9fd75 Mon Sep 17 00:00:00 2001
From: Stephen Hand 
Date: Wed, 25 Oct 2023 10:14:30 +0100
Subject: [PATCH 2/6] Added tests and made some corner case behaviour fixes to
 routing reducer

---
 .../___tests__/states/routing/reducer.test.ts | 435 +++++++++++++++++-
 .../search/ContactDetails/index.tsx           |   4 +-
 .../src/components/search/index.tsx           |   3 -
 plugin-hrm-form/src/states/routing/reducer.ts |  14 +-
 plugin-hrm-form/src/states/routing/types.ts   |   2 +-
 5 files changed, 427 insertions(+), 31 deletions(-)

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 6cfe6e6367..3e16fa50a7 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/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/routing/reducer.ts b/plugin-hrm-form/src/states/routing/reducer.ts
index 5524487973..40fbae8eba 100644
--- a/plugin-hrm-form/src/states/routing/reducer.ts
+++ b/plugin-hrm-form/src/states/routing/reducer.ts
@@ -140,6 +140,10 @@ 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)) {
+      return baseRouteStack;
+    }
     return baseRouteStack.slice(0, -1);
   }
   return baseRouteStack;
@@ -158,9 +162,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';
 };

From 55c3d29d44e8dcefc468008ab267ed2316d94e4d Mon Sep 17 00:00:00 2001
From: Stephen Hand 
Date: Wed, 25 Oct 2023 10:41:40 +0100
Subject: [PATCH 3/6] Added tests for getRoute helpers

---
 .../states/routing/getRoute.test.ts           | 291 ++++++++++++++++++
 plugin-hrm-form/src/states/routing/reducer.ts |   3 +
 2 files changed, 294 insertions(+)
 create mode 100644 plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts

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..3ea335cfc9
--- /dev/null
+++ b/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts
@@ -0,0 +1,291 @@
+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/states/routing/reducer.ts b/plugin-hrm-form/src/states/routing/reducer.ts
index 40fbae8eba..8ce701151a 100644
--- a/plugin-hrm-form/src/states/routing/reducer.ts
+++ b/plugin-hrm-form/src/states/routing/reducer.ts
@@ -142,6 +142,9 @@ const popTopmostRoute = (baseRouteStack: AppRoutes[]): AppRoutes[] => {
     }
     // 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);

From cf905c3c25b991f8827fb105fced3924565bd7e8 Mon Sep 17 00:00:00 2001
From: Stephen Hand 
Date: Wed, 25 Oct 2023 13:45:15 +0100
Subject: [PATCH 4/6] Licence header

---
 .../___tests__/states/routing/getRoute.test.ts   | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts b/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts
index 3ea335cfc9..c42f730d77 100644
--- a/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts
+++ b/plugin-hrm-form/src/___tests__/states/routing/getRoute.test.ts
@@ -1,3 +1,19 @@
+/**
+ * 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';

From d94e71b906642e14add6e2bf06f4a79c6e6164c8 Mon Sep 17 00:00:00 2001
From: Stephen Hand 
Date: Wed, 25 Oct 2023 19:53:21 +0100
Subject: [PATCH 5/6] Save on close reminder POC

---
 .../src/components/CustomCRMContainer.tsx     | 50 ++++++++++++++++---
 1 file changed, 44 insertions(+), 6 deletions(-)

diff --git a/plugin-hrm-form/src/components/CustomCRMContainer.tsx b/plugin-hrm-form/src/components/CustomCRMContainer.tsx
index 98aac9ad57..f12299425c 100644
--- a/plugin-hrm-form/src/components/CustomCRMContainer.tsx
+++ b/plugin-hrm-form/src/components/CustomCRMContainer.tsx
@@ -18,6 +18,7 @@
 import React, { useEffect } from 'react';
 import { connect, ConnectedProps } from 'react-redux';
 import { ITask, withTaskContext } from '@twilio/flex-ui';
+import _ from 'lodash';
 
 import TaskView from './TaskView';
 import { Absolute } from '../styles/HrmStyles';
@@ -26,7 +27,8 @@ 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 { getUnsavedContact } from '../states/contacts/getUnsavedContact';
 
 type OwnProps = {
   task?: ITask;
@@ -35,7 +37,15 @@ type OwnProps = {
 // eslint-disable-next-line no-use-before-define
 type Props = OwnProps & ConnectedProps;
 
-const CustomCRMContainer: React.FC = ({ selectedTaskSid, isAddingOfflineContact, task, dispatch }) => {
+let handleUnloadRef = null;
+
+const CustomCRMContainer: React.FC = ({
+  selectedTaskSid,
+  isAddingOfflineContact,
+  task,
+  dispatch,
+  hasUnsavedChanges,
+}) => {
   useEffect(() => {
     const fetchPopulateCounselors = async () => {
       try {
@@ -50,6 +60,26 @@ const CustomCRMContainer: React.FC = ({ selectedTaskSid, isAddingOfflineC
     fetchPopulateCounselors();
   }, [dispatch]);
 
+  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',
@@ -71,13 +101,21 @@ 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, connectedCase }, flex }: RootState) => {
+  const { selectedTaskSid } = flex.view;
+  const { isAddingOfflineContact } = routing;
+  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,
+    hasUnsavedChanges,
   };
 };
 

From b25bd3f0d2be08a3a34d0b03cefdd02a18e43a77 Mon Sep 17 00:00:00 2001
From: Stephen Hand 
Date: Thu, 2 Nov 2023 08:36:29 +0000
Subject: [PATCH 6/6] Fix merge

---
 plugin-hrm-form/src/components/CustomCRMContainer.tsx | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/plugin-hrm-form/src/components/CustomCRMContainer.tsx b/plugin-hrm-form/src/components/CustomCRMContainer.tsx
index b1382144ab..9b0ef0ee0d 100644
--- a/plugin-hrm-form/src/components/CustomCRMContainer.tsx
+++ b/plugin-hrm-form/src/components/CustomCRMContainer.tsx
@@ -115,7 +115,10 @@ const CustomCRMContainer: React.FC = ({
 
 CustomCRMContainer.displayName = 'CustomCRMContainer';
 
-const mapStateToProps = ({ [namespace]: { routing, activeContacts, configuration, connectedCase }, 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(