Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

CRDCDH-80 Implement Unsaved Changes Prompt #3

Merged
merged 4 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -17,12 +17,15 @@ const theme = createTheme({
},
});

const router = createBrowserRouter(
routeConfig,
);

function App() {
const content = useRoutes(router);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{content}
<RouterProvider router={router} />
</ThemeProvider>
);
}
Expand Down
27 changes: 15 additions & 12 deletions src/components/Contexts/FormContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React, {
type ContextState = {
status: Status;
data: Application;
setData?: (Application) => void;
setData?: (Application) => Promise<boolean>;
error?: string;
};

Expand Down Expand Up @@ -66,19 +66,22 @@ export const FormProvider: FC<ProviderProps> = (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<boolean>((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(() => {
Expand Down
153 changes: 99 additions & 54 deletions src/content/questionnaire/FormView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -28,56 +31,74 @@ const FormView: FC<Props> = ({ section, classes } : Props) => {
const navigate = useNavigate();
const { status, data, error } = useFormContext();
const [activeSection, setActiveSection] = useState(validateSection(section) ? section : "A");
const [blockedNavigate, setBlockedNavigate] = useState<boolean>(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<HTMLButtonElement>(),
submitFormRef: createRef<HTMLButtonElement>(),
saveHandlerRef: useRef<(() => Promise<boolean>) | 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) {
Expand All @@ -98,22 +119,24 @@ const FormView: FC<Props> = ({ section, classes } : Props) => {
<Section section={activeSection} refs={refs} />

<div className={classes.formControls}>
<Button
variant="outlined"
type="button"
onClick={goBack}
disabled={status === FormStatus.SAVING || !sectionKeys[sectionIndex - 1]}
size="large"
startIcon={<BackwardArrowIcon />}
>
Back
</Button>
<Link to={prevSection} style={{ pointerEvents: sectionKeys[sectionIndex - 1] ? "initial" : "none" }}>
<Button
variant="outlined"
type="button"
disabled={status === FormStatus.SAVING || !sectionKeys[sectionIndex - 1]}
size="large"
startIcon={<BackwardArrowIcon />}
>
Back
</Button>
</Link>
<LoadingButton
variant="outlined"
type="button"
ref={refs.saveFormRef}
size="large"
loading={status === FormStatus.SAVING}
onClick={() => refs.saveHandlerRef.current?.()}
>
Save
</LoadingButton>
Expand All @@ -125,17 +148,35 @@ const FormView: FC<Props> = ({ section, classes } : Props) => {
>
Submit
</LoadingButton>
<Button
variant="outlined"
type="button"
onClick={goForward}
disabled={status === FormStatus.SAVING || !sectionKeys[sectionIndex + 1]}
size="large"
endIcon={<ForwardArrowIcon />}
>
Next
</Button>
<Link to={nextSection} style={{ pointerEvents: sectionKeys[sectionIndex + 1] ? "initial" : "none" }}>
<Button
variant="outlined"
type="button"
disabled={status === FormStatus.SAVING || !sectionKeys[sectionIndex + 1]}
size="large"
endIcon={<ForwardArrowIcon />}
>
Next
</Button>
</Link>
</div>

<Dialog open={blockedNavigate}>
<DialogTitle>
Unsaved Changes
</DialogTitle>
<DialogContent>
<DialogContentText>
You have unsaved changes. Your changes will be lost if you leave this section without saving.
Do you want to save your data?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setBlockedNavigate(false)} disabled={status === FormStatus.SAVING}>Cancel</Button>
<LoadingButton onClick={saveAndNavigate} loading={status === FormStatus.SAVING} autoFocus>Save</LoadingButton>
<Button onClick={discardAndNavigate} disabled={status === FormStatus.SAVING} color="error">Discard</Button>
</DialogActions>
</Dialog>
</div>
);
};
Expand Down Expand Up @@ -165,6 +206,10 @@ const styles = () => ({
color: "#fff",
background: "#2A2A2A",
},
"& a": {
color: "inherit",
textDecoration: "none",
}
},
});

Expand Down
39 changes: 26 additions & 13 deletions src/content/questionnaire/sections/A.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,18 @@ const FormSectionA: FC<FormSectionProps> = ({ refs, classes }: FormSectionProps)
const [additionalContacts, setAdditionalContacts] = useState<KeyedContact[]>(data.additionalContacts.map(mapObjectWithKey));

const formRef = useRef<HTMLFormElement>();
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]);

/**
Expand All @@ -50,16 +54,8 @@ const FormSectionA: FC<FormSectionProps> = ({ 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";
Expand All @@ -72,8 +68,25 @@ const FormSectionA: FC<FormSectionProps> = ({ 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;
};

/**
Expand Down
5 changes: 1 addition & 4 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,9 +10,7 @@ const root = ReactDOM.createRoot(
);
root.render(
<HelmetProvider>
<BrowserRouter>
<App />
</BrowserRouter>
<App />
</HelmetProvider>,
);

Expand Down
5 changes: 4 additions & 1 deletion src/types/Globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
type FormSectionProps = {
classes?: any;
refs: {
[key: string]: React.RefObject<HTMLInputElement | HTMLButtonElement>;
saveFormRef: React.RefObject<HTMLButtonElement>;
submitFormRef: React.RefObject<HTMLButtonElement>;
saveHandlerRef: React.MutableRefObject<(() => Promise<boolean>) | null>;
isDirtyHandlerRef: React.MutableRefObject<(() => boolean) | null>;
};
};