diff --git a/plugin-hrm-form/src/___tests__/states/case/hooks/useCase.test.tsx b/plugin-hrm-form/src/___tests__/states/case/hooks/useCase.test.tsx new file mode 100644 index 0000000000..e7b61de629 --- /dev/null +++ b/plugin-hrm-form/src/___tests__/states/case/hooks/useCase.test.tsx @@ -0,0 +1,179 @@ +/** + * 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 * as React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; + +import '../../../mockGetConfig'; +import { useCase } from '../../../../states/case/hooks/useCase'; +import { namespace, connectedCaseBase } from '../../../../states/storeNamespaces'; +import { LOAD_CASE_ACTION } from '../../../../states/case/types'; +import { RecursivePartial } from '../../../RecursivePartial'; +import { RootState } from '../../../../states'; +import { VALID_EMPTY_CASE } from '../../../testCases'; + +jest.mock('../../../../states/case/singleCase', () => ({ + loadCaseAsync: jest.fn(({ caseId }) => ({ + type: 'case-action/load-case', + payload: Promise.resolve(), + meta: { caseId }, + })), +})); + +const mockStore = configureMockStore([]); + +type UseCaseParams = Parameters[0]; + +let capturedResult: ReturnType; + +const TestComponent = (props: UseCaseParams) => { + capturedResult = useCase(props); + return null; +}; + +const buildState = ( + caseEntry?: RecursivePartial, +): RecursivePartial => ({ + [namespace]: { + [connectedCaseBase]: { + cases: caseEntry ? { case1: caseEntry } : {}, + }, + }, +}); + +describe('useCase', () => { + beforeEach(() => { + capturedResult = undefined; + }); + + describe('autoload behaviour', () => { + test('dispatches load action when case is not in state and autoload is true (default)', () => { + const store = mockStore(buildState()); + + render( + + + , + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe(LOAD_CASE_ACTION); + expect(actions[0].meta.caseId).toBe('case1'); + }); + + test('does not dispatch load action when case is already in state', () => { + const store = mockStore(buildState({ connectedCase: VALID_EMPTY_CASE, loading: false, error: null })); + + render( + + + , + ); + + expect(store.getActions()).toHaveLength(0); + }); + + test('does not dispatch load action when autoload is false', () => { + const store = mockStore(buildState()); + + render( + + + , + ); + + expect(store.getActions()).toHaveLength(0); + }); + + test('does not dispatch load action when caseId is falsy', () => { + const store = mockStore(buildState()); + + render( + + + , + ); + + expect(store.getActions()).toHaveLength(0); + }); + }); + + describe('return values', () => { + test('returns connectedCase from state when present', () => { + const store = mockStore(buildState({ connectedCase: VALID_EMPTY_CASE, loading: false, error: null })); + + render( + + + , + ); + + expect(capturedResult.connectedCase).toStrictEqual(VALID_EMPTY_CASE); + }); + + test('returns undefined connectedCase when case is not in state', () => { + const store = mockStore(buildState()); + + render( + + + , + ); + + expect(capturedResult.connectedCase).toBeUndefined(); + }); + + test('returns loading state from store', () => { + const store = mockStore(buildState({ loading: true })); + + render( + + + , + ); + + expect(capturedResult.loading).toBe(true); + }); + + test('returns error state from store', () => { + const error = { message: 'Server Error', status: 500, statusText: 'Internal Server Error' }; + const store = mockStore(buildState({ error, loading: false })); + + render( + + + , + ); + + expect(capturedResult.error).toStrictEqual(error); + }); + + test('returns a forceRefresh function', () => { + const store = mockStore(buildState()); + + render( + + + , + ); + + expect(typeof capturedResult.forceRefresh).toBe('function'); + }); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/states/hooks/useLoadWithRetry.test.tsx b/plugin-hrm-form/src/___tests__/states/hooks/useLoadWithRetry.test.tsx new file mode 100644 index 0000000000..dea81a0ec6 --- /dev/null +++ b/plugin-hrm-form/src/___tests__/states/hooks/useLoadWithRetry.test.tsx @@ -0,0 +1,326 @@ +/** + * 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 * as React from 'react'; +import { act, render } from '@testing-library/react'; + +import { useLoadWithRetry } from '../../../states/hooks/useLoadWithRetry'; +import { ParseFetchErrorResult } from '../../../states/parseFetchError'; + +type HookParams = Parameters[0]; + +let capturedResult: ReturnType; + +const TestComponent = (props: HookParams) => { + capturedResult = useLoadWithRetry(props); + return null; +}; + +const renderHook = (params: HookParams) => render(); + +const noError: ParseFetchErrorResult = undefined; +const serverError: ParseFetchErrorResult = { + message: 'Server Error', + status: 500, + statusText: 'Internal Server Error', +}; +const clientError: ParseFetchErrorResult = { message: 'Not Found', status: 404, statusText: 'Not Found' }; + +describe('useLoadWithRetry', () => { + beforeEach(() => { + jest.useFakeTimers(); + capturedResult = undefined; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('initial load', () => { + test('calls loadFunction when safeToLoad and shouldLoad are true', () => { + const loadFunction = jest.fn(); + renderHook({ loadFunction, error: noError, loading: false, safeToLoad: true, shouldLoad: true, retry: false }); + expect(loadFunction).toHaveBeenCalledTimes(1); + }); + + test('does not call loadFunction when safeToLoad is false', () => { + const loadFunction = jest.fn(); + renderHook({ loadFunction, error: noError, loading: false, safeToLoad: false, shouldLoad: true, retry: false }); + expect(loadFunction).not.toHaveBeenCalled(); + }); + + test('does not call loadFunction when shouldLoad is false', () => { + const loadFunction = jest.fn(); + renderHook({ loadFunction, error: noError, loading: false, safeToLoad: true, shouldLoad: false, retry: false }); + expect(loadFunction).not.toHaveBeenCalled(); + }); + }); + + describe('forceRefresh', () => { + test('returns a forceRefresh function', () => { + const loadFunction = jest.fn(); + renderHook({ loadFunction, error: noError, loading: false, safeToLoad: true, shouldLoad: true, retry: false }); + expect(typeof capturedResult.forceRefresh).toBe('function'); + }); + + test('calling forceRefresh triggers another load when shouldLoad and safeToLoad are true', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: true, + shouldLoad: true, + retry: false, + }); + + // Initial load happens once + expect(loadFunction).toHaveBeenCalledTimes(1); + + // After forceRefresh, load is triggered again + act(() => { + capturedResult.forceRefresh(); + }); + + rerender( + , + ); + + expect(loadFunction).toHaveBeenCalledTimes(2); + }); + + test('forceRefresh does not trigger load when safeToLoad is false', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: false, + shouldLoad: true, + retry: false, + }); + + expect(loadFunction).not.toHaveBeenCalled(); + + act(() => { + capturedResult.forceRefresh(); + }); + + rerender( + , + ); + + expect(loadFunction).not.toHaveBeenCalled(); + }); + }); + + describe('retry behavior', () => { + test('retries after server error (5xx) when retry is true', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: true, + shouldLoad: true, + retry: true, + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + + // Simulate a server error response + rerender( + , + ); + + // Advance timers to trigger the retry (first retry: 2^0 * 1000ms = 1000ms) + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(loadFunction).toHaveBeenCalledTimes(2); + }); + + test('does not retry on 4xx client errors', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: true, + shouldLoad: true, + retry: true, + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + + // Simulate a 4xx error + rerender( + , + ); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + // Should still only be called once (no retry on 4xx) + expect(loadFunction).toHaveBeenCalledTimes(1); + }); + + test('does not retry when retry is false', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: true, + shouldLoad: true, + retry: false, + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + }); + + test('does not retry while a load is still in progress', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: true, + shouldLoad: true, + retry: true, + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + + // Error occurs but loading is still true + rerender( + , + ); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + }); + + test('does not start a new retry when safeToLoad becomes false after error', () => { + const loadFunction = jest.fn(); + const { rerender } = renderHook({ + loadFunction, + error: noError, + loading: false, + safeToLoad: true, + shouldLoad: true, + retry: true, + }); + + expect(loadFunction).toHaveBeenCalledTimes(1); + + // Trigger an error to start a retry + rerender( + , + ); + + // Advance far beyond first retry window + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(loadFunction).toHaveBeenCalledTimes(2); + + // safeToLoad becomes false — should not trigger another retry cycle + rerender( + , + ); + + act(() => { + jest.advanceTimersByTime(10000); + }); + + // No additional retry started because safeToLoad is false + expect(loadFunction).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/plugin-hrm-form/src/components/case/Case.tsx b/plugin-hrm-form/src/components/case/Case.tsx index 1c61592c65..7aa31e00ff 100644 --- a/plugin-hrm-form/src/components/case/Case.tsx +++ b/plugin-hrm-form/src/components/case/Case.tsx @@ -69,7 +69,6 @@ const Case: React.FC = ({ task, handleClose, onNewCaseSaved = () => Promi const contactId = useSelector((state: RootState) => selectContextContactId(state, task.taskSid, 'case', 'home')); const counselorsHash = useSelector(selectCounselorsHash); const definitionVersions = useSelector(selectDefinitionVersions); - const currentDefinitionVersion = useSelector(selectCurrentDefinitionVersion); const routing = currentRoute as CaseRoute; const contextContact = useSelector( (state: RootState) => selectContactStateByContactId(state, contactId)?.savedContact, @@ -90,8 +89,6 @@ const Case: React.FC = ({ task, handleClose, onNewCaseSaved = () => Promi const { connectedCase, loading: loadingCase } = useCase({ caseId: connectedCaseId, - referenceId: `case-details-${task.taskSid}`, - refresh: true, // force a reload }); const can = React.useMemo(() => { diff --git a/plugin-hrm-form/src/components/case/caseOverview/EditCaseOverview.tsx b/plugin-hrm-form/src/components/case/caseOverview/EditCaseOverview.tsx index 288a4bf9cf..19175a3ab1 100644 --- a/plugin-hrm-form/src/components/case/caseOverview/EditCaseOverview.tsx +++ b/plugin-hrm-form/src/components/case/caseOverview/EditCaseOverview.tsx @@ -35,7 +35,7 @@ import { } from '../../../styles'; import { RootState } from '../../../states'; import { newCloseModalAction, newGoBackAction } from '../../../states/routing/actions'; -import type { Case, CaseOverview, CustomITask, StandaloneITask } from '../../../types/types'; +import type { CustomITask, StandaloneITask } from '../../../types/types'; import { recordingErrorHandler } from '../../../fullStory'; import { CaseSummaryWorkingCopy } from '../../../states/case/types'; import CloseCaseDialog from '../CloseCaseDialog'; @@ -146,9 +146,10 @@ const EditCaseOverview: React.FC = ({ task, can }) => { return { status: connectedCase.status, ...connectedCase.info, + ...(workingCopy ?? {}), ...formValues, }; - }, [connectedCase.info, connectedCase.status, getValues]); + }, [connectedCase.info, connectedCase.status, getValues, workingCopy]); useEffect(() => { if (!workingCopy) { diff --git a/plugin-hrm-form/src/components/caseMergingBanners/ContactAddedToCaseBanner.tsx b/plugin-hrm-form/src/components/caseMergingBanners/ContactAddedToCaseBanner.tsx index 663092d59d..691f3b180d 100644 --- a/plugin-hrm-form/src/components/caseMergingBanners/ContactAddedToCaseBanner.tsx +++ b/plugin-hrm-form/src/components/caseMergingBanners/ContactAddedToCaseBanner.tsx @@ -56,8 +56,6 @@ const ContactAddedToCaseBanner: React.FC = ({ taskId, contactId }) => { }; const { connectedCase } = useCase({ caseId: contact.caseId, - referenceId: `contact-added-to-case-banner-${contact.id}`, - refresh: false, }); /* diff --git a/plugin-hrm-form/src/components/customIntegrations/uscr/DispatchIncidentButton.tsx b/plugin-hrm-form/src/components/customIntegrations/uscr/DispatchIncidentButton.tsx index e5c63281e5..20b7e19455 100644 --- a/plugin-hrm-form/src/components/customIntegrations/uscr/DispatchIncidentButton.tsx +++ b/plugin-hrm-form/src/components/customIntegrations/uscr/DispatchIncidentButton.tsx @@ -77,9 +77,8 @@ const DispatchIncidentButton: React.FC = ({ contactId }) => { return `dispatch-incident-button-${savedContact.id}-${rand}`; }, [savedContact.id]); - const { connectedCase, loading: caseLoading } = useCase({ + const { loading: caseLoading } = useCase({ caseId: savedContact.caseId, - referenceId, }); const { sections } = useCaseSections({ caseId: savedContact.caseId, @@ -106,7 +105,6 @@ const DispatchIncidentButton: React.FC = ({ contactId }) => { const valid = await trigger(); if (valid) { await saveDraft(); - // We use a regular dispatch here because we handle the error from where it is called. await dispatch(newIncidentDispatchAction(savedContact)); Notifications.showNotificationSingle(dispatchSuccessNotification); } diff --git a/plugin-hrm-form/src/states/case/hooks/useCase.ts b/plugin-hrm-form/src/states/case/hooks/useCase.ts index b8bac21dd0..0f68c72438 100644 --- a/plugin-hrm-form/src/states/case/hooks/useCase.ts +++ b/plugin-hrm-form/src/states/case/hooks/useCase.ts @@ -24,22 +24,12 @@ import type { Case } from '../../../types/types'; import type { RootState } from '../..'; import { useLoadWithRetry } from '../../hooks/useLoadWithRetry'; -const useCaseLoader = ({ - caseId, - autoload = true, - refresh = true, -}: { - caseId: Case['id']; - autoload?: boolean; - refresh?: boolean; -}) => { +const useCaseLoader = ({ caseId, autoload = true }: { caseId: Case['id']; autoload?: boolean }) => { const dispatch = useDispatch(); const error = useSelector((state: RootState) => selectCaseByCaseId(state, caseId)?.error); const loading = useSelector((state: RootState) => selectCaseByCaseId(state, caseId)?.loading); - const connectedCase = useSelector((state: RootState) => selectCaseByCaseId(state, caseId)?.connectedCase); - - const exists = Boolean(connectedCase); + const isAlreadyInState = useSelector((state: RootState) => Boolean(selectCaseByCaseId(state, caseId)?.connectedCase)); const loadCase = useCallback(() => { if (!caseId) { @@ -50,7 +40,7 @@ const useCaseLoader = ({ }, [caseId, dispatch]); const safeToLoad = Boolean(caseId); - const shouldLoad = autoload || refresh; + const shouldLoad = autoload && !isAlreadyInState; const loader = useLoadWithRetry({ error, @@ -69,22 +59,11 @@ const useCaseLoader = ({ }; // eslint-disable-next-line import/no-unused-modules -export const useCase = ({ - caseId, - referenceId: _referenceId, // Kept for backward compatibility; no longer used with GC-based state management - autoload = true, - refresh = false, -}: { - caseId: Case['id']; - /** @deprecated No longer used; cases are managed via garbage collection */ - referenceId?: string; - autoload?: boolean; - refresh?: boolean; -}) => { +export const useCase = ({ caseId, autoload = true }: { caseId: Case['id']; autoload?: boolean }) => { const connectedCase = useSelector((state: RootState) => selectCaseByCaseId(state, caseId)?.connectedCase); return { connectedCase, - ...useCaseLoader({ caseId, autoload, refresh }), + ...useCaseLoader({ caseId, autoload }), }; };