buster/web/src/components/ui/layouts/PreventNavigation.tsx

222 lines
5.9 KiB
TypeScript

'use client';
import { useMemoizedFn, useMount } from '@/hooks';
import { useRouter } from 'next/navigation';
import { useState, useRef, useEffect, useMemo } from 'react';
import React from 'react';
import { AppModal } from '../modal/AppModal';
type PreventNavigationProps = {
isDirty: boolean;
title: string;
description: string;
cancelText?: string;
okText?: string;
onOk: () => Promise<void>;
onCancel: () => Promise<void>;
onClose?: () => void;
doNotLeavePageOnOkay?: boolean;
};
export const PreventNavigation: React.FC<PreventNavigationProps> = React.memo(
({
isDirty,
cancelText = 'Discard changes',
okText = 'Save changes',
doNotLeavePageOnOkay,
...props
}) => {
const [canceling, setCanceling] = useState(false);
const [okaying, setOkaying] = useState(false);
const [leavingPage, setLeavingPage] = useState(false);
const router = useRouter();
/**
* Function that will be called when the user selects `yes` in the confirmation modal,
* redirected to the selected page.
*/
const confirmationFn = useRef<() => void>(() => {});
// Used to make popstate event trigger when back button is clicked.
// Without this, the popstate event will not fire because it needs there to be a href to return.
/**
* Used to prevent navigation when use click in navigation `<Link />` or `<a />`.
* @param e The triggered event.
*/
const handleClick = useMemoizedFn((event: MouseEvent) => {
let target = event.target as HTMLElement;
let href: string | null = null;
// Traverse up the DOM tree looking for an anchor tag with href
while (target && !href) {
if (target instanceof HTMLAnchorElement && target.href) {
href = target.href;
break;
}
target = target.parentElement as HTMLElement;
}
if (isDirty) {
event.preventDefault();
event.stopPropagation();
console.log(href);
confirmationFn.current = () => {
if (href) router.push(href);
};
setLeavingPage(true);
}
});
/**
* Used to prevent navigation when use `back` browser buttons.
*/
const handlePopState = useMemoizedFn(() => {
if (isDirty) {
window.history.pushState(null, document.title, window.location.href);
confirmationFn.current = () => {
console.warn('TODO - make sure we can navigate back to the correct page');
router.back();
};
setLeavingPage(true);
} else {
window.history.back();
}
});
/**
* Used to prevent navigation when reload page or navigate to another page, in diffenret origin.
* @param e The triggered event.
*/
const handleBeforeUnload = useMemoizedFn((e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = true;
}
});
useEffect(() => {
/* *************************** Open listeners ************************** */
document.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', handleClick);
});
window.addEventListener('popstate', handlePopState);
window.addEventListener('beforeunload', handleBeforeUnload);
/* ************** Return from useEffect closing listeners ************** */
return () => {
document.querySelectorAll('a').forEach((link) => {
link.removeEventListener('click', handleClick);
});
window.removeEventListener('popstate', handlePopState);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isDirty]);
const onClose = useMemoizedFn(async () => {
setLeavingPage(false);
await props.onClose?.();
confirmationFn.current = () => {};
});
const noCallback = useMemoizedFn(async () => {
setLeavingPage(false);
await props.onCancel?.();
confirmationFn.current();
confirmationFn.current = () => {};
});
const yesCallback = useMemoizedFn(async () => {
await props.onOk?.();
if (!doNotLeavePageOnOkay) confirmationFn.current();
setLeavingPage(false);
confirmationFn.current = () => {};
});
useMount(() => {
window.history.pushState(null, document.title, window.location.href);
});
if (!isDirty) return null;
return (
<LeavingDialog
{...props}
canceling={canceling}
okaying={okaying}
cancelText={cancelText}
okText={okText}
isOpen={leavingPage}
onClose={onClose}
noCallback={noCallback}
yesCallback={yesCallback}
/>
);
}
);
PreventNavigation.displayName = 'PreventNavigation';
const LeavingDialog: React.FC<{
isOpen: boolean;
noCallback: () => void;
yesCallback: () => void;
onClose: () => void;
title: string;
description: string;
cancelText: string;
okText: string;
canceling: boolean;
okaying: boolean;
}> = React.memo(
({
onClose,
isOpen,
okaying,
canceling,
noCallback,
yesCallback,
title,
description,
okText,
cancelText
}) => {
const disableButtons = okaying || canceling;
const memoizedHeader = useMemo(() => {
return { title, description };
}, [title, description]);
const memoizedFooter = useMemo(() => {
return {
primaryButton: {
text: cancelText,
onClick: noCallback,
loading: canceling,
disabled: disableButtons
},
secondaryButton: {
text: okText,
onClick: yesCallback,
loading: okaying,
disabled: disableButtons
}
};
}, [okaying, canceling, disableButtons, noCallback, yesCallback, cancelText, okText]);
return (
<AppModal
open={isOpen}
onClose={onClose}
header={memoizedHeader}
footer={memoizedFooter}></AppModal>
);
}
);
LeavingDialog.displayName = 'LeavingDialog';