1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-12-01 12:24:28 +01:00

(feat) alert unsaved changes

if the user tries to quit the product form, he will
be alerted about unsaved changes, if any
This commit is contained in:
Sylvain 2022-09-14 14:51:54 +02:00
parent c381c985d2
commit 6b224d7db1
5 changed files with 130 additions and 11 deletions

View File

@ -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<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
export const FabModal: React.FC<FabModalProps> = ({ 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<FabModalProps> = ({ 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 (
<Modal isOpen={isOpen}
className={`fab-modal fab-modal-${width} ${className || ''}`}
overlayClassName="fab-modal-overlay"
onRequestClose={toggleModal}>
{closeButton && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.fab_modal.close')}</FabButton>}
onRequestClose={handleClose}>
{closeButton && <FabButton className="modal-btn--close" onClick={handleClose}>{t('app.shared.fab_modal.close')}</FabButton>}
<div className="fab-modal-header">
{!customHeader && <h1>{ title }</h1>}
{customHeader && customHeader}

View File

@ -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<TFieldValues> {
uiRouter: UIRouter,
@ -9,20 +12,101 @@ interface UnsavedFormAlertProps<TFieldValues> {
}
/**
* 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 = <TFieldValues extends FieldValues>({ uiRouter, formState, children }: PropsWithChildren<UnsavedFormAlertProps<TFieldValues>>) => {
const { t } = useTranslation('shared');
const [showAlertModal, setShowAlertModal] = useState<boolean>(false);
const [promise, setPromise] = useState<Deferred<boolean>>(null);
const [dirty, setDirty] = useState<boolean>(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<boolean>|void => {
if (isDirty) {
toggleAlertModal();
const userChoicePromise = new Deferred<boolean>();
setPromise(userChoicePromise);
return userChoicePromise.promise;
}
};
// memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty
const alertDirty = useCallback<() => Promise<boolean>|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 (
<div className="unsaved-form-alert">
{children}
<FabModal isOpen={showAlertModal}
toggleModal={toggleAlertModal}
confirmButton={t('app.shared.unsaved_form_alert.confirmation_button')}
title={t('app.shared.unsaved_form_alert.modal_title')}
onConfirm={handleConfirmation}
onClose={handleCancel}
closeButton>
{t('app.shared.unsaved_form_alert.confirmation_message')}
</FabModal>
</div>
);
};

View File

@ -227,8 +227,8 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
<FabButton className="main-action-btn" onClick={handleSubmit(saveProduct)}>{t('app.admin.store.product_form.save')}</FabButton>
</div>
</header>
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
<form className="product-form" onSubmit={handleSubmit(onSubmit)}>
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
<div className='tabs'>
<p className={!stockTab ? 'is-active' : ''} onClick={() => setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}</p>
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>

View File

@ -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<T> {
public readonly promise: Promise<T>;
private resolveFn!: (value: T | PromiseLike<T>) => void;
private rejectFn!: (reason?: unknown) => void;
public constructor () {
this.promise = new Promise<T>((resolve, reject) => {
this.resolveFn = resolve;
this.rejectFn = reject;
});
}
public reject (reason?: unknown): void {
this.rejectFn(reason);
}
public resolve (param: T): void {
this.resolveFn(param);
}
}

View File

@ -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"