diff --git a/react/hooks/useConfirmExit.js b/react/hooks/useConfirmExit.js deleted file mode 100644 index 9d03eaad4f..0000000000 --- a/react/hooks/useConfirmExit.js +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useRef } from 'react' -import useEventListener from './useEventListener' - -/** - * When provided a message, will warn the user before exiting the page - * - * Warning: The confirmation message is required but will usually - * be replaced by an internal message by the browser, and never displayed. - * - * A falsy error message would deactivate to confirmation popup. - * - * @param {string|null} message - Confirmation message - * @param {function} callback - will be executed before returning - */ -export default function useConfirmExit(message, callback) { - // Using a ref in order to have an event listener that does not - // need to be deregistered, recreated and registered again at each - // message or callback change. If not, the lag introduced by the - // useEffect inside useEventListener may create wrong behaviours - // for fast changing calls to useConfirmExit. - const state = useRef() - state.current = { message, callback } - const beforeunload = useCallback(event => { - state.current.callback && state.current.callback() - const returnValue = state.current.message || null - if (returnValue) event.returnValue = returnValue - return returnValue - }, []) - useEventListener(window, 'beforeunload', beforeunload) -} diff --git a/react/hooks/useConfirmExit.spec.js b/react/hooks/useConfirmExit.spec.js deleted file mode 100644 index 7a2770939d..0000000000 --- a/react/hooks/useConfirmExit.spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import useConfirmExit from './useConfirmExit' -import { renderHook } from '@testing-library/react-hooks' - -const triggerBeforeUnload = () => { - const event = new Event('beforeunload') - return window.dispatchEvent(event) -} - -describe('useConfirmExit', () => { - it('should subscribe to window.onbeforeunload at mount', async () => { - const cb = jest.fn() - renderHook(() => useConfirmExit('message', cb)) - - triggerBeforeUnload() - expect(cb).toHaveBeenCalledTimes(1) - }) - - it('should unsubscribe to window.onbeforeunload at dismount', async () => { - const cb = jest.fn() - const { unmount } = renderHook(() => useConfirmExit('message', cb)) - unmount() - - triggerBeforeUnload() - expect(cb).toHaveBeenCalledTimes(0) - }) - - it('should allow a null message', async () => { - const cb = jest.fn() - renderHook(() => useConfirmExit(null, cb)) - - triggerBeforeUnload() - expect(cb).toHaveBeenCalledTimes(1) - }) - - it('should allow a null callback', async () => { - renderHook(() => useConfirmExit('message')) - - triggerBeforeUnload() - }) -}) diff --git a/react/hooks/useConfirmExit/index.js b/react/hooks/useConfirmExit/index.js new file mode 100644 index 0000000000..5fcc4fa3bc --- /dev/null +++ b/react/hooks/useConfirmExit/index.js @@ -0,0 +1,151 @@ +import React, { useCallback, useRef, useState } from 'react' +import PropTypes from 'prop-types' + +import useEventListener from '../useEventListener' +import Modal from '../../Modal' +import withLocales from '../../I18n/withLocales' + +import en from './locales/en.json' +import fr from './locales/fr.json' + +/** + * Confirmation modal + * @private + * @param {string} message - Confirmation message + * @param {string} title - Title of the modal + * @param {function} onConfirm - will be executed on confirmation + * @param {function} onCancel - will be executed on cancelation + */ +function ConfirmModal({ t, title, message, onConfirm, onCancel }) { + return ( + <Modal + closable={false} + mobileFullscreen={false} + primaryAction={onConfirm} + primaryType="regular" + primaryText={t('useConfirmExit.leave')} + secondaryAction={onCancel} + secondaryType="secondary" + secondaryText={t('useConfirmExit.back')} + description={message || t('useConfirmExit.message')} + title={title || t('useConfirmExit.title')} + /> + ) +} +ConfirmModal.PropTypes = { + message: PropTypes.string, + title: PropTypes.string, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +} +const dictRequire = { en, fr } +const LocalizedConfirmModal = withLocales(dictRequire)(ConfirmModal) + +/** + * Go to the requested destination (URL or function) + * + * @param {string|function} destination + */ +function go(destination) { + if (typeof destination === 'function') { + destination() + } else if (typeof destination === 'string') { + document.location = destination + } else { + throw new Error(`Unknown location where to jump to`) + } +} + +function isActivated(activate) { + return typeof activate === 'function' ? activate() : activate +} + +/** + * @typedef useConfirmExitResponse + * @property {function} requestToLeave - gets an URL or function, + * triggers a confirmation modal and redirect the browser to this URL + * or call this function if the user confirms. + * @property {function} exitConfirmationModal - React component + * that will show a confirmation modal when requested by requestToLeave + * and nothing otherwise + */ + +/** + * When provided a message, will warn the user before exiting the page + * + * When the browser detects a page unload (go to another website or + * leave the window/tab), it will show a native popup asking for + * confirmation. This popup may show the `message` but will usually + * use a native message from the browser. + * + * If the user confirm he wants to leave, `onLeave` will be executed. + * This function may not be able to execute async code. + * + * @param {bool|function} activate - (return) falsy to deactivate the behaviour + * @param {string} message - Confirmation message + * @param {string} title - Title of the modal + * @param {function} onLeave - will be executed before returning + * @returns {useConfirmExitResponse} + */ +export default function useConfirmExit({ + activate = true, + onLeave, + message, + title +}) { + // `onbeforeunload` event on the browser: + // Using a ref in order to have an event listener that does not + // need to be deregistered, recreated and registered again at each + // message or callback change. If not, the lag introduced by the + // useEffect inside useEventListener may create wrong behaviours + // for fast changing calls to useConfirmExit. + const state = useRef() + state.current = { message, onLeave, activate } + const beforeunload = useCallback(event => { + const activated = isActivated(state.current.activate) + activated && state.current.onLeave && state.current.onLeave() + const returnValue = activated ? state.current.message : null + if (returnValue) event.returnValue = returnValue + return returnValue + }, []) + useEventListener(window, 'beforeunload', beforeunload) + + // contains an URL of function given to `requestToLeave` + // any truthy value will trigger the ExitConfirmationModal + const [modalDest, setModalDest] = useState(false) + const onCloseModalRequest = useCallback(() => { + setModalDest(false) + }, [setModalDest]) + + // call this with an URL or a function to trigger the ExitConfirmationModal + const requestToLeave = useCallback( + where => { + if (isActivated(state.current.activate)) { + setModalDest(() => where) + } else { + go(where) + } + }, + [state, setModalDest] + ) + + // null when the modal is closed, a Modal otherwise + const onConfirm = useCallback(() => { + onLeave && onLeave() + go(modalDest) + }, [modalDest, onLeave]) + + // return the modal if opened + const modal = modalDest && ( + <LocalizedConfirmModal + message={message} + title={title} + onCancel={onCloseModalRequest} + onConfirm={onConfirm} + /> + ) + return { + requestToLeave, + exitConfirmationModal: modal + } +} diff --git a/react/hooks/useConfirmExit/index.spec.js b/react/hooks/useConfirmExit/index.spec.js new file mode 100644 index 0000000000..0e32017032 --- /dev/null +++ b/react/hooks/useConfirmExit/index.spec.js @@ -0,0 +1,183 @@ +import useConfirmExit from './' +import { renderHook, act } from '@testing-library/react-hooks' +import { isElement } from 'react-dom/test-utils' + +function triggerBeforeUnload() { + const event = new Event('beforeunload') + return window.dispatchEvent(event) +} + +expect.extend({ + toBeReactElement(received) { + if (received && isElement(received)) { + return { + pass: true, + message: `expected ${typeof received} should not be a React Element` + } + } else { + return { + pass: false, + message: `expected ${typeof received} should be a React Element` + } + } + }, + toBeFunctionWithAtLeastArgs(received, number) { + if ( + received && + typeof received === 'function' && + typeof received.length === 'number' && + received.length >= number + ) { + return { + pass: true, + message: `expected ${typeof received} should be a function where arguments ${received && + received.length} should be at least ${number}` + } + } else { + return { + pass: true, + message: `expected ${typeof received} should not be a function or where arguments ${received && + received.length} should be less than ${number}` + } + } + } +}) + +const message = 'message' +const onLeave = jest.fn() +const where = jest.fn() + +describe('useConfirmExit', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + describe('return values', () => { + describe('requestToLeave', () => { + it('should exist in returned values', async () => { + const { result } = renderHook(() => + useConfirmExit({ message, onLeave }) + ) + expect(result.current).toHaveProperty('requestToLeave') + }) + + it('should be a function', async () => { + const { result } = renderHook(() => + useConfirmExit({ message, onLeave }) + ) + expect(result.current.requestToLeave).toBeInstanceOf(Function) + }) + + it('should be accept an argument', async () => { + const { result } = renderHook(() => + useConfirmExit({ message, onLeave }) + ) + expect(result.current.requestToLeave).toBeFunctionWithAtLeastArgs(1) + }) + + it('should trigger a react component in the modal', async () => { + const { rerender, result } = renderHook(() => + useConfirmExit({ message, onLeave, activate: true }) + ) + expect(result.current.exitConfirmationModal).toBeFalsy() + const requestToLeave = result.current.requestToLeave + act(() => requestToLeave(where)) + rerender() + expect(result.current.exitConfirmationModal).toBeReactElement() + }) + + it('should not call the destination', async () => { + const { rerender, result } = renderHook(() => + useConfirmExit({ message, onLeave, activate: true }) + ) + expect(result.current.exitConfirmationModal).toBeFalsy() + const requestToLeave = result.current.requestToLeave + act(() => requestToLeave(where)) + rerender() + expect(where).not.toHaveBeenCalled() + }) + + it('should not trigger a react component in the modal for activate=false', async () => { + const { rerender, result } = renderHook(() => + useConfirmExit({ message, onLeave, activate: false }) + ) + expect(result.current.exitConfirmationModal).toBeFalsy() + const requestToLeave = result.current.requestToLeave + act(() => requestToLeave(where)) + rerender() + expect(result.current.exitConfirmationModal).toBeFalsy() + }) + + it('should go to the destination for activate=false', async () => { + const { rerender, result } = renderHook(() => + useConfirmExit({ message, onLeave, activate: false }) + ) + expect(result.current.exitConfirmationModal).toBeFalsy() + const requestToLeave = result.current.requestToLeave + act(() => requestToLeave(where)) + rerender() + expect(where).toHaveBeenCalledTimes(1) + }) + }) + + describe('exitConfirmationModal', () => { + it('should exist in returned values', () => { + const { result } = renderHook(() => + useConfirmExit({ message, onLeave }) + ) + expect(result.current).toHaveProperty('exitConfirmationModal') + }) + + it('should not be displayed be default', async () => { + const { result } = renderHook(() => + useConfirmExit({ message, onLeave }) + ) + expect(result.current.exitConfirmationModal).toBeFalsy() + }) + }) + }) + + describe('onbeforeunload', () => { + it('should subscribe to window.onbeforeunload at mount', async () => { + renderHook(() => useConfirmExit({ message, onLeave })) + + triggerBeforeUnload() + expect(onLeave).toHaveBeenCalledTimes(1) + }) + + it('should unsubscribe to window.onbeforeunload at dismount', async () => { + const { unmount } = renderHook(() => useConfirmExit({ message, onLeave })) + unmount() + + triggerBeforeUnload() + expect(onLeave).toHaveBeenCalledTimes(0) + }) + + it('should allow a null message', async () => { + renderHook(() => useConfirmExit({ message, onLeave })) + + triggerBeforeUnload() + expect(onLeave).toHaveBeenCalledTimes(1) + }) + + it('should allow a null callback', async () => { + renderHook(() => useConfirmExit({ message })) + + triggerBeforeUnload() + }) + + it('should do nothing for activate=false', async () => { + renderHook(() => useConfirmExit({ message, onLeave, activate: false })) + + triggerBeforeUnload() + expect(onLeave).not.toHaveBeenCalled() + }) + + it('should works for activate=true', async () => { + renderHook(() => useConfirmExit({ message, onLeave, activate: true })) + + triggerBeforeUnload() + expect(onLeave).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/react/hooks/useConfirmExit/locales/en.json b/react/hooks/useConfirmExit/locales/en.json new file mode 100644 index 0000000000..c7960fe10c --- /dev/null +++ b/react/hooks/useConfirmExit/locales/en.json @@ -0,0 +1,8 @@ +{ + "useConfirmExit": { + "back": "Back", + "leave": "Leave", + "title": "Do you want to leave?", + "message": "Some changes are not saved yet. Do you really want to leave and lose these changes?" + } +} diff --git a/react/hooks/useConfirmExit/locales/fr.json b/react/hooks/useConfirmExit/locales/fr.json new file mode 100644 index 0000000000..57bc866a6a --- /dev/null +++ b/react/hooks/useConfirmExit/locales/fr.json @@ -0,0 +1,8 @@ +{ + "useConfirmExit": { + "back": "Retour", + "leave": "Quitter", + "title": "Voulez-vous quitter ?", + "message": "Des modifications n'ont pas encore pu ĂȘtre enregistrĂ©es. Voulez-vous vraiment quitter et perdre ces modifications ?" + } +}