Skip to content

Commit

Permalink
✨ to handle multiple prevent exit (#805)
Browse files Browse the repository at this point in the history
* ✨ to handle multiple prevent exit

* 👌 match in mode standard

* 👌 fix comments

* 👌 add PreventExitListenerResult to export

* 👌 some types and extras

* 👌 add some more
  • Loading branch information
arunachalam-monk authored Jul 18, 2024
1 parent 59a0597 commit c7562a4
Show file tree
Hide file tree
Showing 13 changed files with 206 additions and 56 deletions.
2 changes: 1 addition & 1 deletion configs/test-utils/src/__mocks__/@monkvision/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() })),
};
13 changes: 9 additions & 4 deletions packages/common/README/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions packages/common/src/PreventExit/hooks.ts
Original file line number Diff line number Diff line change
@@ -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 <div onClick={allowRedirect}>My Component</div>;
* }
* ```
*/
export function usePreventExit(
preventExit: boolean,
): Pick<PreventExitListenerResult, 'allowRedirect'> {
const { cleanup, setPreventExit, allowRedirect } = useMemo(createPreventExitListener, []);
useEffect(() => setPreventExit(preventExit), [preventExit]);
useEffect(() => cleanup, []);
return useObjectMemo({ allowRedirect });
}
1 change: 1 addition & 0 deletions packages/common/src/PreventExit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './hooks';
59 changes: 59 additions & 0 deletions packages/common/src/PreventExit/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const keys: Array<symbol> = [];
const allPreventExitState: Record<symbol, boolean> = {};

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();
},
};
}
1 change: 0 additions & 1 deletion packages/common/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ export * from './useSearchParams';
export * from './useInterval';
export * from './useAsyncInterval';
export * from './useObjectMemo';
export * from './usePreventExit';
export * from './useThumbnail';
25 changes: 0 additions & 25 deletions packages/common/src/hooks/usePreventExit.ts

This file was deleted.

5 changes: 3 additions & 2 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
46 changes: 46 additions & 0 deletions packages/common/test/PreventExit/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createPreventExitListener>>;
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);
});
});
49 changes: 49 additions & 0 deletions packages/common/test/PreventExit/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createPreventExitListener } from '../../src/PreventExit/store';

describe('preventExitStore', () => {
let listener1: ReturnType<typeof createPreventExitListener>;
let listener2: ReturnType<typeof createPreventExitListener>;

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);
});
});
19 changes: 0 additions & 19 deletions packages/common/test/hooks/usePreventExit.test.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useI18nSync,
useLoadingState,
useObjectMemo,
usePreventExit,
useWindowDimensions,
} from '@monkvision/common';
import {
Expand Down Expand Up @@ -218,6 +219,7 @@ export function PhotoCapture({
}
setCurrentScreen(PhotoCaptureScreen.CAMERA);
};
const { allowRedirect } = usePreventExit(sightState.sightsTaken.length !== 0);
const handleGalleryValidate = () => {
startTasks()
.then(() => {
Expand All @@ -226,6 +228,7 @@ export function PhotoCapture({
captureCompleted: true,
sightSelected: 'inspection-completed',
});
allowRedirect();
onComplete?.();
})
.catch((err) => {
Expand All @@ -236,7 +239,6 @@ export function PhotoCapture({
const isViolatingEnforcedOrientation =
enforceOrientation &&
(enforceOrientation === DeviceOrientation.PORTRAIT) !== dimensions.isPortrait;

const hudProps: Omit<PhotoCaptureHUDProps, keyof CameraHUDProps> = {
sights,
selectedSight: sightState.selectedSight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -447,4 +447,11 @@ describe('PhotoCapture component', () => {

unmount();
});

it('should ask the user for confirmation before exit', () => {
const props = createProps();
const { unmount } = render(<PhotoCapture {...props} />);
expect(usePreventExit).toHaveBeenCalled();
unmount();
});
});

0 comments on commit c7562a4

Please sign in to comment.