diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index 6bdcc168b..520a1d654 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -118,6 +118,6 @@ export = { useAsyncInterval: jest.fn(), useSearchParams: jest.fn(() => ({ get: jest.fn(() => null) })), useObjectMemo: jest.fn((obj) => obj), - usePreventExit: jest.fn(), getLanguage: jest.fn(), + usePreventExit: jest.fn(() => ({ allowRedirect: jest.fn() })), }; diff --git a/packages/common/README/HOOKS.md b/packages/common/README/HOOKS.md index 45403abd1..b56aa0f69 100644 --- a/packages/common/README/HOOKS.md +++ b/packages/common/README/HOOKS.md @@ -137,14 +137,19 @@ function TestComponent() { Custom hook used to get a translation function tObj that translates TranslationObjects. ### usePreventExit -```tsx +```ts import { usePreventExit } from '@monkvision/common'; -function TestComponent() { - usePreventExit(); - return <>...; +function MyComponent() { + const { allowRedirect } = usePreventExit(true); + + const anyEvent = useCallback(() => { + allowRedirect(); + /** ... */ + }, [allowRedirect]) } ``` + This hook is used to prevent the user from leaving the page by displaying a confirmation dialog when the user tries to leave the page. diff --git a/packages/common/src/PreventExit/hooks.ts b/packages/common/src/PreventExit/hooks.ts new file mode 100644 index 000000000..2e556483a --- /dev/null +++ b/packages/common/src/PreventExit/hooks.ts @@ -0,0 +1,25 @@ +import { useEffect, useMemo } from 'react'; +import { PreventExitListenerResult, createPreventExitListener } from './store'; +import { useObjectMemo } from '../hooks'; + +/** + * Custom hook that allows preventing the user from exiting the page or navigating away. + * + * @param preventExit - A boolean value indicating whether to prevent the user from exiting the page or not. + * + * @example + * ```tsx + * function MyComponent() { + * const { allowRedirect } = usePreventExit(true); + * return
My Component
; + * } + * ``` + */ +export function usePreventExit( + preventExit: boolean, +): Pick { + const { cleanup, setPreventExit, allowRedirect } = useMemo(createPreventExitListener, []); + useEffect(() => setPreventExit(preventExit), [preventExit]); + useEffect(() => cleanup, []); + return useObjectMemo({ allowRedirect }); +} diff --git a/packages/common/src/PreventExit/index.ts b/packages/common/src/PreventExit/index.ts new file mode 100644 index 000000000..4cc90d02b --- /dev/null +++ b/packages/common/src/PreventExit/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/packages/common/src/PreventExit/store.ts b/packages/common/src/PreventExit/store.ts new file mode 100644 index 000000000..5b71caee7 --- /dev/null +++ b/packages/common/src/PreventExit/store.ts @@ -0,0 +1,59 @@ +const keys: Array = []; +const allPreventExitState: Record = {}; + +function arePreventExitRemaining(): boolean { + if (keys.map((key) => allPreventExitState[key]).every((i) => i === false)) { + window.onbeforeunload = null; + return true; + } + return false; +} + +function publish(id: symbol, preventExit: boolean): void { + allPreventExitState[id] = preventExit; + if (!arePreventExitRemaining()) + window.onbeforeunload = (e) => { + e.preventDefault(); + return 'prevent-exit'; + }; +} + +/** + * Returns a listener which can used to calculate the state of prevent exit. + */ +export interface PreventExitListenerResult { + /** + * Callback used to set the value indicating if direct exit of the page is currently allowed or not. + */ + setPreventExit: (preventExit: boolean) => void; + /** + * Allows the user to leave the page without confirmation temporarily. + * This should be used when the developer wants to explicitly allow navigation. + */ + allowRedirect: () => void; + /** + * Performs garbage collection by removing the preventExit state associated with the component. + * This should be used when the component is unmounted. + */ + cleanup: () => void; +} + +/** + * Creates a listener function that manages the preventExit state of a component. + */ +export function createPreventExitListener(): PreventExitListenerResult { + const key = Symbol('PreventExitListener'); + allPreventExitState[key] = true; + keys.push(key); + + return { + setPreventExit: (preventExit) => publish(key, preventExit), + allowRedirect: () => { + window.onbeforeunload = null; + }, + cleanup: () => { + delete allPreventExitState[key]; + arePreventExitRemaining(); + }, + }; +} diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index 13e2d1474..8f54beca7 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -10,5 +10,4 @@ export * from './useSearchParams'; export * from './useInterval'; export * from './useAsyncInterval'; export * from './useObjectMemo'; -export * from './usePreventExit'; export * from './useThumbnail'; diff --git a/packages/common/src/hooks/usePreventExit.ts b/packages/common/src/hooks/usePreventExit.ts deleted file mode 100644 index 528a532a6..000000000 --- a/packages/common/src/hooks/usePreventExit.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from 'react'; - -/** - * Hook that prevents the user from leaving the page without confirmation. - * - * @example - * ```tsx - * import { usePreventExit } from '@monkvision/common'; - * - * function MyComponent() { - * usePreventExit(); - * return
My Component
; - * } - * ``` - */ -export function usePreventExit() { - useEffect(() => { - window.onbeforeunload = () => { - return 'Are you sure you want to leave?'; - }; - return () => { - window.onbeforeunload = null; - }; - }, []); -} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index c93c4d6a0..cbc74b6ab 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,6 +1,7 @@ +export * from './PreventExit'; +export * from './apps'; +export * from './hooks'; export * from './i18n'; export * from './state'; export * from './theme'; export * from './utils'; -export * from './hooks'; -export * from './apps'; diff --git a/packages/common/test/PreventExit/hooks.test.ts b/packages/common/test/PreventExit/hooks.test.ts new file mode 100644 index 000000000..f051560e4 --- /dev/null +++ b/packages/common/test/PreventExit/hooks.test.ts @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePreventExit } from '../../src/PreventExit/hooks'; +import { createPreventExitListener } from '../../src/PreventExit/store'; + +jest.mock('../../src/PreventExit/store', () => ({ + createPreventExitListener: jest.fn(() => ({ + cleanup: jest.fn(), + setPreventExit: jest.fn(), + allowRedirect: jest.fn(), + })), +})); +describe('PreventExit hook usePreventExit', () => { + let spyCreatePreventExitListener: jest.SpyInstance>; + beforeEach(() => { + spyCreatePreventExitListener = jest.spyOn( + { createPreventExitListener }, + 'createPreventExitListener', + ); + }); + afterEach(() => jest.clearAllMocks()); + + it('should clean up when unmount', () => { + const { unmount } = renderHook(() => usePreventExit(true)); + unmount(); + expect(spyCreatePreventExitListener.mock.results.at(-1)?.value.cleanup).toHaveBeenCalledTimes( + 1, + ); + }); + + it('should set preventExit when value changes', async () => { + const { rerender } = renderHook((props) => usePreventExit(props), { + initialProps: true, + }); + expect( + spyCreatePreventExitListener.mock.results.at(-1)?.value.setPreventExit, + ).toHaveBeenCalledTimes(1); + rerender(false); + expect( + spyCreatePreventExitListener.mock.results.at(-1)?.value.setPreventExit, + ).toHaveBeenCalledTimes(2); + rerender(false); + expect( + spyCreatePreventExitListener.mock.results.at(-1)?.value.setPreventExit, + ).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/common/test/PreventExit/store.test.ts b/packages/common/test/PreventExit/store.test.ts new file mode 100644 index 000000000..725223c48 --- /dev/null +++ b/packages/common/test/PreventExit/store.test.ts @@ -0,0 +1,49 @@ +import { createPreventExitListener } from '../../src/PreventExit/store'; + +describe('preventExitStore', () => { + let listener1: ReturnType; + let listener2: ReturnType; + + beforeEach(() => { + listener1 = createPreventExitListener(); + listener2 = createPreventExitListener(); + }); + + afterEach(() => { + listener1.cleanup(); + listener2.cleanup(); + }); + + it('should prevent exit: listener 1', () => { + listener1.setPreventExit(true); + listener2.setPreventExit(true); + expect(window.onbeforeunload).not.toBe(null); + listener1.setPreventExit(true); + listener2.setPreventExit(false); + expect(window.onbeforeunload).not.toBe(null); + listener1.setPreventExit(false); + listener2.setPreventExit(true); + expect(window.onbeforeunload).not.toBe(null); + listener1.setPreventExit(false); + listener2.setPreventExit(false); + expect(window.onbeforeunload).toBe(null); + }); + + it('should allow redirect: listener 1', () => { + const preventExit = [true, false]; + preventExit.forEach((i) => { + preventExit.forEach((j) => { + listener1.setPreventExit(i); + listener2.setPreventExit(j); + listener1.allowRedirect(); + expect(window.onbeforeunload).toBe(null); + }); + }); + }); + + it('should allow redirect: lister 2', () => { + listener2.cleanup(); + listener1.cleanup(); + expect(window.onbeforeunload).toBe(null); + }); +}); diff --git a/packages/common/test/hooks/usePreventExit.test.ts b/packages/common/test/hooks/usePreventExit.test.ts deleted file mode 100644 index 968b8163d..000000000 --- a/packages/common/test/hooks/usePreventExit.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { usePreventExit } from '../../src'; - -describe('usePreventExit hook', () => { - it('should properly set and unset the onbeforeunload event handler', () => { - const { unmount } = renderHook(usePreventExit); - expect(window.onbeforeunload).toBeDefined(); - unmount(); - expect(window.onbeforeunload).toBeNull(); - }); - - it('should prompt the user when trying to leave the page', () => { - const { unmount } = renderHook(usePreventExit); - const event = new Event('beforeunload', { cancelable: true }); - const returnValue = window.onbeforeunload?.(event); - expect(returnValue).toEqual('Are you sure you want to leave?'); - unmount(); - }); -}); diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 7e72115c1..9b60fdca7 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -4,6 +4,7 @@ import { useI18nSync, useLoadingState, useObjectMemo, + usePreventExit, useWindowDimensions, } from '@monkvision/common'; import { @@ -218,6 +219,7 @@ export function PhotoCapture({ } setCurrentScreen(PhotoCaptureScreen.CAMERA); }; + const { allowRedirect } = usePreventExit(sightState.sightsTaken.length !== 0); const handleGalleryValidate = () => { startTasks() .then(() => { @@ -226,6 +228,7 @@ export function PhotoCapture({ captureCompleted: true, sightSelected: 'inspection-completed', }); + allowRedirect(); onComplete?.(); }) .catch((err) => { @@ -236,7 +239,6 @@ export function PhotoCapture({ const isViolatingEnforcedOrientation = enforceOrientation && (enforceOrientation === DeviceOrientation.PORTRAIT) !== dimensions.isPortrait; - const hudProps: Omit = { sights, selectedSight: sightState.selectedSight, diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index f1767b6fa..5d873443c 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -51,12 +51,12 @@ jest.mock('../../src/PhotoCapture/hooks', () => ({ useTracking: jest.fn(), })); -import { act, render, waitFor } from '@testing-library/react'; import { Camera } from '@monkvision/camera-web'; -import { expectPropsOnChildMock } from '@monkvision/test-utils'; -import { useI18nSync, useLoadingState } from '@monkvision/common'; +import { useI18nSync, useLoadingState, usePreventExit } from '@monkvision/common'; import { BackdropDialog, InspectionGallery } from '@monkvision/common-ui-web'; import { useMonitoring } from '@monkvision/monitoring'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { act, render, waitFor } from '@testing-library/react'; import { PhotoCapture, PhotoCaptureHUD, PhotoCaptureProps } from '../../src'; import { useAdaptiveCameraConfig, @@ -447,4 +447,11 @@ describe('PhotoCapture component', () => { unmount(); }); + + it('should ask the user for confirmation before exit', () => { + const props = createProps(); + const { unmount } = render(); + expect(usePreventExit).toHaveBeenCalled(); + unmount(); + }); });