diff --git a/src/App.tsx b/src/App.tsx index d5b2260a2..df64448e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import { useRoutes } from 'react-router-dom'; +import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { ThemeProvider, CssBaseline, createTheme } from '@mui/material'; -import router from './router'; +import routeConfig from './router'; const theme = createTheme({ typography: { @@ -17,12 +17,15 @@ const theme = createTheme({ }, }); +const router = createBrowserRouter( + routeConfig, +); + function App() { - const content = useRoutes(router); return ( - {content} + ); } diff --git a/src/components/Contexts/FormContext.tsx b/src/components/Contexts/FormContext.tsx index 670cd1a96..a1988de85 100644 --- a/src/components/Contexts/FormContext.tsx +++ b/src/components/Contexts/FormContext.tsx @@ -10,7 +10,7 @@ import React, { type ContextState = { status: Status; data: Application; - setData?: (Application) => void; + setData?: (Application) => Promise; error?: string; }; @@ -66,19 +66,22 @@ export const FormProvider: FC = (props) => { // Here we update the state and send the data to the API // otherwise we can just update the local state (i.e. within form sections) - const setData = (data: Application) => { - console.log("[UPDATING DATA]"); - console.log("prior state", state); + const setData = async (data: Application) => { + return new Promise((resolve, reject) => { + console.log("[UPDATING DATA]"); + console.log("prior state", state); - const newState = { ...state, data }; - setState({ ...newState, status: Status.SAVING }); - console.log("new state", newState); + const newState = { ...state, data }; + setState({ ...newState, status: Status.SAVING }); + console.log("new state", newState); - // simulate the save event - setTimeout(() => { - setState({ ...newState, status: Status.LOADED }); - console.log("saved"); - }, 1500); + // simulate the save event + setTimeout(() => { + setState({ ...newState, status: Status.LOADED }); + console.log("saved"); + resolve(true); + }, 1500); + }); }; useEffect(() => { diff --git a/src/content/questionnaire/FormView.tsx b/src/content/questionnaire/FormView.tsx index 2b6185de3..e26c06ef0 100644 --- a/src/content/questionnaire/FormView.tsx +++ b/src/content/questionnaire/FormView.tsx @@ -1,6 +1,9 @@ -import React, { FC, createRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Button } from '@mui/material'; +import React, { FC, createRef, useEffect, useRef, useState } from 'react'; +import { + Link, useNavigate, + unstable_useBlocker as useBlocker, unstable_Blocker as Blocker +} from 'react-router-dom'; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { WithStyles, withStyles } from "@mui/styles"; import ForwardArrowIcon from '@mui/icons-material/ArrowForwardIos'; @@ -28,56 +31,74 @@ const FormView: FC = ({ section, classes } : Props) => { const navigate = useNavigate(); const { status, data, error } = useFormContext(); const [activeSection, setActiveSection] = useState(validateSection(section) ? section : "A"); + const [blockedNavigate, setBlockedNavigate] = useState(false); + const sectionKeys = Object.keys(map); const sectionIndex = sectionKeys.indexOf(activeSection); + const prevSection = sectionKeys[sectionIndex - 1] ? `/questionnaire/${data?.id}/${sectionKeys[sectionIndex - 1]}` : "#"; + const nextSection = sectionKeys[sectionIndex + 1] ? `/questionnaire/${data?.id}/${sectionKeys[sectionIndex + 1]}` : "#"; const refs = { saveFormRef: createRef(), submitFormRef: createRef(), + saveHandlerRef: useRef<(() => Promise) | null>(null), + isDirtyHandlerRef: useRef<(() => boolean) | null>(null), }; - /** - * Trigger navigation to a specific section - * - * @param section string - * @returns void - */ - const navigateToSection = (section: string) => { - if (!validateSection(section.toUpperCase())) { - return; - } - if (section.toUpperCase() === activeSection) { - return; + // Intercept React Router navigation actions with unsaved changes + const blocker: Blocker = useBlocker(() => { + if (refs.isDirtyHandlerRef.current?.()) { + setBlockedNavigate(true); + return true; } - navigate(`/questionnaire/${data?.id}/${section}`); - setActiveSection(section); - }; + return false; + }); + + // Intercept browser navigation actions (e.g. closing the tab) with unsaved changes + useEffect(() => { + const unloadHandler = (event: BeforeUnloadEvent) => { + if (refs.isDirtyHandlerRef.current?.()) { + event.preventDefault(); + event.returnValue = 'You have unsaved form changes. Are you sure you want to leave?'; + } + }; + + window.addEventListener('beforeunload', unloadHandler); + + return () => { + window.removeEventListener('beforeunload', unloadHandler); + }; + }); + + useEffect(() => { + setActiveSection(validateSection(section) ? section : "A"); + }, [section]); /** - * Traverse to the previous section + * Provides a save handler for the Unsaved Changes + * dialog. Will save the form and then navigate to the + * blocked section. * - * @returns void + * @returns {void} */ - const goBack = () => { - const previousSection = sectionKeys[sectionIndex - 1]; - - if (previousSection) { - navigateToSection(previousSection); - } + const saveAndNavigate = async () => { + // Wait for the save handler to complete + await refs.saveHandlerRef.current?.(); + setBlockedNavigate(false); + blocker.proceed(); }; /** - * Traverse to the next section + * Provides a discard handler for the Unsaved Changes + * dialog. Will discard the form changes and then navigate to the + * blocked section. * - * @returns void + * @returns {void} */ - const goForward = () => { - const nextSection = sectionKeys[sectionIndex + 1]; - - if (nextSection) { - navigateToSection(nextSection); - } + const discardAndNavigate = () => { + setBlockedNavigate(false); + blocker.proceed(); }; if (status === FormStatus.LOADING) { @@ -98,22 +119,24 @@ const FormView: FC = ({ section, classes } : Props) => {
- + + + refs.saveHandlerRef.current?.()} > Save @@ -125,17 +148,35 @@ const FormView: FC = ({ section, classes } : Props) => { > Submit - + + +
+ + + + Unsaved Changes + + + + You have unsaved changes. Your changes will be lost if you leave this section without saving. + Do you want to save your data? + + + + + Save + + + ); }; @@ -165,6 +206,10 @@ const styles = () => ({ color: "#fff", background: "#2A2A2A", }, + "& a": { + color: "inherit", + textDecoration: "none", + } }, }); diff --git a/src/content/questionnaire/sections/A.tsx b/src/content/questionnaire/sections/A.tsx index 9a33a3d95..8af43341c 100644 --- a/src/content/questionnaire/sections/A.tsx +++ b/src/content/questionnaire/sections/A.tsx @@ -31,14 +31,18 @@ const FormSectionA: FC = ({ refs, classes }: FormSectionProps) const [additionalContacts, setAdditionalContacts] = useState(data.additionalContacts.map(mapObjectWithKey)); const formRef = useRef(); - const { saveFormRef, submitFormRef } = refs; + const { + saveFormRef, submitFormRef, saveHandlerRef, isDirtyHandlerRef + } = refs; useEffect(() => { if (!saveFormRef.current || !submitFormRef.current) { return; } - saveFormRef.current.onclick = saveForm; saveFormRef.current.style.display = "initial"; submitFormRef.current.style.display = "none"; + + saveHandlerRef.current = saveForm; + isDirtyHandlerRef.current = () => !isEqual(getFormObject(), data); }, [refs]); /** @@ -50,16 +54,8 @@ const FormSectionA: FC = ({ refs, classes }: FormSectionProps) * * @returns {void} */ - const saveForm = () => { - if (!formRef.current) { return; } - - const formObject = parseForm(formRef.current, { nullify: false }); - const combinedData = { ...cloneDeep(data), ...formObject }; - - // Reset additional contacts if none are provided - if (!formObject.additionalContacts || formObject.additionalContacts.length === 0) { - combinedData.additionalContacts = []; - } + const saveForm = async () => { + const combinedData = getFormObject(); // Update section status const newStatus = formRef.current.reportValidity() ? "Completed" : "In Progress"; @@ -72,8 +68,25 @@ const FormSectionA: FC = ({ refs, classes }: FormSectionProps) // Skip state update if there are no changes if (!isEqual(combinedData, data)) { - setData(combinedData); + const r = await setData(combinedData); + return r; } + + return true; + }; + + const getFormObject = () => { + if (!formRef.current) { return false; } + + const formObject = parseForm(formRef.current, { nullify: false }); + const combinedData = { ...cloneDeep(data), ...formObject }; + + // Reset additional contacts if none are provided + if (!formObject.additionalContacts || formObject.additionalContacts.length === 0) { + combinedData.additionalContacts = []; + } + + return combinedData; }; /** diff --git a/src/index.tsx b/src/index.tsx index fe16ff1b4..73c6f7f5e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { HelmetProvider } from 'react-helmet-async'; -import { BrowserRouter } from 'react-router-dom'; import App from './App'; import * as serviceWorker from './serviceWorker'; import reportWebVitals from './reportWebVitals'; @@ -11,9 +10,7 @@ const root = ReactDOM.createRoot( ); root.render( - - - + , ); diff --git a/src/types/Globals.d.ts b/src/types/Globals.d.ts index 7f0858dcd..683b165e2 100644 --- a/src/types/Globals.d.ts +++ b/src/types/Globals.d.ts @@ -1,6 +1,9 @@ type FormSectionProps = { classes?: any; refs: { - [key: string]: React.RefObject; + saveFormRef: React.RefObject; + submitFormRef: React.RefObject; + saveHandlerRef: React.MutableRefObject<(() => Promise) | null>; + isDirtyHandlerRef: React.MutableRefObject<(() => boolean) | null>; }; };