From 6b224d7db1f5720a77632478ff0031fc62bcb4f9 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 14 Sep 2022 14:51:54 +0200 Subject: [PATCH] (feat) alert unsaved changes if the user tries to quit the product form, he will be alerted about unsaved changes, if any --- .../javascript/components/base/fab-modal.tsx | 15 ++- .../components/form/unsaved-form-alert.tsx | 98 +++++++++++++++++-- .../components/store/product-form.tsx | 2 +- app/frontend/src/javascript/lib/deferred.ts | 22 +++++ config/locales/app.shared.en.yml | 4 + 5 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 app/frontend/src/javascript/lib/deferred.ts diff --git a/app/frontend/src/javascript/components/base/fab-modal.tsx b/app/frontend/src/javascript/components/base/fab-modal.tsx index 84fa446d6..0318f3dea 100644 --- a/app/frontend/src/javascript/components/base/fab-modal.tsx +++ b/app/frontend/src/javascript/components/base/fab-modal.tsx @@ -23,6 +23,7 @@ interface FabModalProps { customHeader?: ReactNode, customFooter?: ReactNode, onConfirm?: (event: BaseSyntheticEvent) => void, + onClose?: (event: BaseSyntheticEvent) => void, preventConfirm?: boolean, onCreation?: () => void, onConfirmSendFormId?: string, @@ -31,7 +32,7 @@ interface FabModalProps { /** * This component is a template for a modal dialog that wraps the application style */ -export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => { +export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, onClose, preventConfirm, onCreation, onConfirmSendFormId }) => { const { t } = useTranslation('shared'); useEffect(() => { @@ -40,12 +41,20 @@ export const FabModal: React.FC = ({ title, isOpen, toggleModal, } }, [isOpen]); + /** + * Callback triggered when the user request to close the modal without confirming. + */ + const handleClose = (event) => { + if (typeof onClose === 'function') onClose(event); + toggleModal(); + }; + return ( - {closeButton && {t('app.shared.fab_modal.close')}} + onRequestClose={handleClose}> + {closeButton && {t('app.shared.fab_modal.close')}}
{!customHeader &&

{ title }

} {customHeader && customHeader} diff --git a/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx index 007789eda..87e93a7ea 100644 --- a/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx +++ b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx @@ -1,7 +1,10 @@ -import React, { PropsWithChildren, useEffect } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { UIRouter } from '@uirouter/angularjs'; import { FormState } from 'react-hook-form/dist/types/form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FabModal } from '../base/fab-modal'; +import Deferred from '../../lib/deferred'; +import { useTranslation } from 'react-i18next'; interface UnsavedFormAlertProps { uiRouter: UIRouter, @@ -9,20 +12,101 @@ interface UnsavedFormAlertProps { } /** - * Alert the user about unsaved changes in the given form, before leaving the current page + * Alert the user about unsaved changes in the given form, before leaving the current page. + * This component is highly dependent of these external libraries: + * - [react-hook-form](https://react-hook-form.com/) + * - [ui-router](https://ui-router.github.io/) */ export const UnsavedFormAlert = ({ uiRouter, formState, children }: PropsWithChildren>) => { + const { t } = useTranslation('shared'); + + const [showAlertModal, setShowAlertModal] = useState(false); + const [promise, setPromise] = useState>(null); + const [dirty, setDirty] = useState(formState.isDirty); + + useEffect(() => { + const submitStatus = (!formState.isSubmitting && (!formState.isSubmitted || !formState.isSubmitSuccessful)); + setDirty(submitStatus && Object.keys(formState.dirtyFields).length > 0); + }, [formState]); + + /** + * Check if the current form is dirty. If so, show the confirmation modal and return a promise + */ + const alertOnDirtyForm = (isDirty: boolean): Promise|void => { + if (isDirty) { + toggleAlertModal(); + const userChoicePromise = new Deferred(); + setPromise(userChoicePromise); + return userChoicePromise.promise; + } + }; + + // memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty + const alertDirty = useCallback<() => Promise|void>(() => alertOnDirtyForm(dirty), [dirty]); + + // we should place this useEffect after the useCallback declaration (because it's a scoped variable) useEffect(() => { const { transitionService, globals: { current } } = uiRouter; - transitionService.onBefore({ from: current.name }, () => { - const { isDirty } = formState; - console.log('transition start', isDirty); - }); - }, []); + const deregisters = transitionService.onBefore({ from: current.name }, alertDirty); + return () => { + deregisters(); + }; + }, [alertDirty]); + + /** + * When the user tries to close the current page (tab/window), we alert him about unsaved changes + */ + const alertOnExit = (event: BeforeUnloadEvent, isDirty: boolean) => { + if (isDirty) { + event.preventDefault(); + event.returnValue = ''; + } + }; + + // memoised version of the alertOnExit function, will be updated only when the form becames dirty + const alertExit = useCallback<(event: BeforeUnloadEvent) => void>((event) => alertOnExit(event, dirty), [dirty]); + + // we should place this useEffect after the useCallback declaration (because it's a scoped variable) + useEffect(() => { + window.addEventListener('beforeunload', alertExit); + return () => { + window.removeEventListener('beforeunload', alertExit); + }; + }, [alertExit]); + + /** + * Hide/show the alert modal "you have some unsaved content, are you sure you want to leave?" + */ + const toggleAlertModal = () => { + setShowAlertModal(!showAlertModal); + }; + + /** + * Callback triggered when the user has choosen: continue and exit + */ + const handleConfirmation = () => { + promise.resolve(true); + }; + + /** + * Callback triggered when the user has choosen: cancel and stay + */ + const handleCancel = () => { + promise.resolve(false); + }; return (
{children} + + {t('app.shared.unsaved_form_alert.confirmation_message')} +
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e0f8cf4e1..6bb1a36d6 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -227,8 +227,8 @@ export const ProductForm: React.FC = ({ product, title, onSucc {t('app.admin.store.product_form.save')}
-
+

setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}

setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}

diff --git a/app/frontend/src/javascript/lib/deferred.ts b/app/frontend/src/javascript/lib/deferred.ts new file mode 100644 index 000000000..811adc07e --- /dev/null +++ b/app/frontend/src/javascript/lib/deferred.ts @@ -0,0 +1,22 @@ +// This is a kind of promise you can resolve from outside the function callback. +// Credits to https://stackoverflow.com/a/71158892/1039377 +export default class Deferred { + public readonly promise: Promise; + private resolveFn!: (value: T | PromiseLike) => void; + private rejectFn!: (reason?: unknown) => void; + + public constructor () { + this.promise = new Promise((resolve, reject) => { + this.resolveFn = resolve; + this.rejectFn = reject; + }); + } + + public reject (reason?: unknown): void { + this.rejectFn(reason); + } + + public resolve (param: T): void { + this.resolveFn(param); + } +} diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index c3c5e65ef..a3976daa9 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -564,3 +564,7 @@ en: order_item: total: "Total" client: "Client" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save"