Ask user confirmation before leaving an unsaved form

This commit is contained in:
Pierre HUBERT 2023-08-09 08:28:37 +02:00
parent 049b9bdd53
commit 1128b5ebd4
17 changed files with 142 additions and 52 deletions

View File

@ -1,5 +1,11 @@
import React from "react"; import React from "react";
import { Route, Routes } from "react-router-dom"; import {
Route,
RouterProvider,
Routes,
createBrowserRouter,
createRoutesFromElements,
} from "react-router-dom";
import "./App.css"; import "./App.css";
import { AuthApi } from "./api/AuthApi"; import { AuthApi } from "./api/AuthApi";
import { DeleteAccountRoute } from "./routes/DeleteAccountRoute"; import { DeleteAccountRoute } from "./routes/DeleteAccountRoute";
@ -41,9 +47,9 @@ export function App(): React.ReactElement {
setSignedIn: (s) => setSignedIn(s), setSignedIn: (s) => setSignedIn(s),
}; };
return ( const router = createBrowserRouter(
<AuthContextK.Provider value={context}> createRoutesFromElements(
<Routes> <>
<Route path="delete_account" element={<DeleteAccountRoute />} /> <Route path="delete_account" element={<DeleteAccountRoute />} />
{signedIn ? ( {signedIn ? (
@ -80,7 +86,13 @@ export function App(): React.ReactElement {
<Route path="*" element={<NotFoundRoute />} /> <Route path="*" element={<NotFoundRoute />} />
</Route> </Route>
)} )}
</Routes> </>
)
);
return (
<AuthContextK.Provider value={context}>
<RouterProvider router={router} />
</AuthContextK.Provider> </AuthContextK.Provider>
); );
} }

View File

@ -2,7 +2,7 @@ import React from "react";
import { TextInputDialog } from "./TextInputDialog"; import { TextInputDialog } from "./TextInputDialog";
import { ServerApi } from "../api/ServerApi"; import { ServerApi } from "../api/ServerApi";
import { FamilyApi } from "../api/FamilyApi"; import { FamilyApi } from "../api/FamilyApi";
import { useAlert } from "../context_providers/AlertDialogProvider"; import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
export function CreateFamilyDialog(p: { export function CreateFamilyDialog(p: {
open: boolean; open: boolean;

View File

@ -2,7 +2,7 @@ import React from "react";
import { TextInputDialog } from "./TextInputDialog"; import { TextInputDialog } from "./TextInputDialog";
import { ServerApi } from "../api/ServerApi"; import { ServerApi } from "../api/ServerApi";
import { FamilyApi, JoinFamilyResult } from "../api/FamilyApi"; import { FamilyApi, JoinFamilyResult } from "../api/FamilyApi";
import { useAlert } from "../context_providers/AlertDialogProvider"; import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
export function JoinFamilyDialog(p: { export function JoinFamilyDialog(p: {
open: boolean; open: boolean;

View File

@ -11,11 +11,11 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { ConfirmDialogProvider } from "./context_providers/ConfirmDialogProvider"; import { ConfirmDialogProvider } from "./hooks/context_providers/ConfirmDialogProvider";
import { AlertDialogProvider } from "./context_providers/AlertDialogProvider"; import { AlertDialogProvider } from "./hooks/context_providers/AlertDialogProvider";
import { AsyncWidget } from "./widgets/AsyncWidget"; import { AsyncWidget } from "./widgets/AsyncWidget";
import { SnackbarProvider } from "./context_providers/SnackbarProvider"; import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider";
import { DarkThemeProvider } from "./context_providers/DarkThemeProvider"; import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider";
async function init() { async function init() {
try { try {
@ -25,7 +25,6 @@ async function init() {
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter>
<DarkThemeProvider> <DarkThemeProvider>
<AlertDialogProvider> <AlertDialogProvider>
<ConfirmDialogProvider> <ConfirmDialogProvider>
@ -42,7 +41,6 @@ async function init() {
</ConfirmDialogProvider> </ConfirmDialogProvider>
</AlertDialogProvider> </AlertDialogProvider>
</DarkThemeProvider> </DarkThemeProvider>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
} catch (e) { } catch (e) {

View File

@ -3,9 +3,9 @@ import React from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { AuthApi } from "../api/AuthApi"; import { AuthApi } from "../api/AuthApi";
import { DeleteAccountTokenInfo, UserApi } from "../api/UserApi"; import { DeleteAccountTokenInfo, UserApi } from "../api/UserApi";
import { useAlert } from "../context_providers/AlertDialogProvider"; import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { useConfirm } from "../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
export function DeleteAccountRoute(): React.ReactElement { export function DeleteAccountRoute(): React.ReactElement {
const alert = useAlert(); const alert = useAlert();

View File

@ -14,9 +14,9 @@ import React from "react";
import { Family, FamilyApi } from "../api/FamilyApi"; import { Family, FamilyApi } from "../api/FamilyApi";
import { CreateFamilyDialog } from "../dialogs/CreateFamilyDialog"; import { CreateFamilyDialog } from "../dialogs/CreateFamilyDialog";
import { JoinFamilyDialog } from "../dialogs/JoinFamilyDialog"; import { JoinFamilyDialog } from "../dialogs/JoinFamilyDialog";
import { useAlert } from "../context_providers/AlertDialogProvider"; import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { useConfirm } from "../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
import { RouterLink } from "../widgets/RouterLink"; import { RouterLink } from "../widgets/RouterLink";
import { TimeWidget } from "../widgets/TimeWidget"; import { TimeWidget } from "../widgets/TimeWidget";

View File

@ -13,9 +13,9 @@ import {
import React from "react"; import React from "react";
import { ServerApi } from "../api/ServerApi"; import { ServerApi } from "../api/ServerApi";
import { ReplacePasswordResponse, User, UserApi } from "../api/UserApi"; import { ReplacePasswordResponse, User, UserApi } from "../api/UserApi";
import { useAlert } from "../context_providers/AlertDialogProvider"; import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { useUser } from "../widgets/BaseAuthenticatedPage"; import { useUser } from "../widgets/BaseAuthenticatedPage";
import { useConfirm } from "../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
import { PasswordInput } from "../widgets/PasswordInput"; import { PasswordInput } from "../widgets/PasswordInput";
import { formatDate } from "../widgets/TimeWidget"; import { formatDate } from "../widgets/TimeWidget";

View File

@ -1,21 +1,22 @@
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import { Button, Grid, Stack } from "@mui/material"; import { Button, Grid, Stack } from "@mui/material";
import React from "react"; import React from "react";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Member, MemberApi } from "../../api/MemberApi"; import { Member, MemberApi } from "../../api/MemberApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { useAlert } from "../../context_providers/AlertDialogProvider"; import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
import { useConfirm } from "../../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
import { AsyncWidget } from "../../widgets/AsyncWidget";
import { useFamily } from "../../widgets/BaseFamilyRoute"; import { useFamily } from "../../widgets/BaseFamilyRoute";
import { ConfirmLeaveWithoutSaveDialog } from "../../widgets/ConfirmLeaveWithoutSaveDialog";
import { FamilyPageTitle } from "../../widgets/FamilyPageTitle"; import { FamilyPageTitle } from "../../widgets/FamilyPageTitle";
import { PropEdit } from "../../widgets/PropEdit"; import { PropEdit } from "../../widgets/PropEdit";
import { PropertiesBox } from "../../widgets/PropertiesBox"; import { PropertiesBox } from "../../widgets/PropertiesBox";
import { SexSelection } from "../../widgets/SexSelection"; import { SexSelection } from "../../widgets/SexSelection";
import { useSnackbar } from "../../context_providers/SnackbarProvider";
import { AsyncWidget } from "../../widgets/AsyncWidget";
/** /**
* Create a new member route * Create a new member route
@ -24,6 +25,7 @@ export function FamilyCreateMemberRoute(): React.ReactElement {
const alert = useAlert(); const alert = useAlert();
const snackbar = useSnackbar(); const snackbar = useSnackbar();
const [shouldQuit, setShouldQuit] = React.useState(false);
const n = useNavigate(); const n = useNavigate();
const family = useFamily(); const family = useFamily();
@ -33,6 +35,7 @@ export function FamilyCreateMemberRoute(): React.ReactElement {
// TODO : trigger update // TODO : trigger update
setShouldQuit(true);
n(family.family.URL(`member/${r.id}`)); n(family.family.URL(`member/${r.id}`));
snackbar(`La fiche pour ${r.fullName} a été créée avec succès !`); snackbar(`La fiche pour ${r.fullName} a été créée avec succès !`);
} catch (e) { } catch (e) {
@ -41,13 +44,19 @@ export function FamilyCreateMemberRoute(): React.ReactElement {
} }
}; };
const cancel = () => {
setShouldQuit(true);
n(family.family.URL("members"));
};
return ( return (
<MemberPage <MemberPage
member={Member.New(family.family.family_id)} member={Member.New(family.family.family_id)}
creating={true} creating={true}
editing={true} editing={true}
onCancel={() => n(family.family.URL("members"))} onCancel={cancel}
onSave={create} onSave={create}
shouldAllowLeaving={shouldQuit}
/> />
); );
} }
@ -118,6 +127,8 @@ export function FamilyEditMemberRoute(): React.ReactElement {
const alert = useAlert(); const alert = useAlert();
const snackbar = useSnackbar(); const snackbar = useSnackbar();
const [shouldQuit, setShouldQuit] = React.useState(false);
const family = useFamily(); const family = useFamily();
const { memberId } = useParams(); const { memberId } = useParams();
@ -126,6 +137,11 @@ export function FamilyEditMemberRoute(): React.ReactElement {
setMember(await MemberApi.GetSingle(family.familyId, Number(memberId))); setMember(await MemberApi.GetSingle(family.familyId, Number(memberId)));
}; };
const cancel = () => {
setShouldQuit(true);
n(family.family.URL(`member/${member!.id}`));
};
const save = async (m: Member) => { const save = async (m: Member) => {
try { try {
await MemberApi.Update(m); await MemberApi.Update(m);
@ -134,6 +150,7 @@ export function FamilyEditMemberRoute(): React.ReactElement {
// TODO : update family hook info // TODO : update family hook info
setShouldQuit(true);
n(family.family.URL(`member/${member!.id}`)); n(family.family.URL(`member/${member!.id}`));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -151,8 +168,9 @@ export function FamilyEditMemberRoute(): React.ReactElement {
member={member!} member={member!}
creating={false} creating={false}
editing={true} editing={true}
onCancel={() => n(family.family.URL(`member/${member!.id}`))} onCancel={cancel}
onSave={save} onSave={save}
shouldAllowLeaving={shouldQuit}
/> />
)} )}
/> />
@ -163,6 +181,7 @@ export function MemberPage(p: {
member: Member; member: Member;
editing: boolean; editing: boolean;
creating: boolean; creating: boolean;
shouldAllowLeaving?: boolean;
onCancel?: () => void; onCancel?: () => void;
onSave?: (m: Member) => void; onSave?: (m: Member) => void;
onRequestEdit?: () => void; onRequestEdit?: () => void;
@ -170,7 +189,6 @@ export function MemberPage(p: {
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
// TODO : add confirmation when leaving page https://dev.to/bangash1996/detecting-user-leaving-page-with-react-router-dom-v602-33ni
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
const [member, setMember] = React.useState(structuredClone(p.member)); const [member, setMember] = React.useState(structuredClone(p.member));
@ -198,6 +216,9 @@ export function MemberPage(p: {
return ( return (
<div style={{ maxWidth: "2000px", margin: "auto" }}> <div style={{ maxWidth: "2000px", margin: "auto" }}>
<ConfirmLeaveWithoutSaveDialog
shouldBlock={changed && p.shouldAllowLeaving !== true}
/>
<div <div
style={{ style={{
display: "flex", display: "flex",

View File

@ -12,8 +12,8 @@ import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FamilyApi } from "../../api/FamilyApi"; import { FamilyApi } from "../../api/FamilyApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { useAlert } from "../../context_providers/AlertDialogProvider"; import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
import { useConfirm } from "../../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
import { useFamily } from "../../widgets/BaseFamilyRoute"; import { useFamily } from "../../widgets/BaseFamilyRoute";
import { formatDate } from "../../widgets/TimeWidget"; import { formatDate } from "../../widgets/TimeWidget";

View File

@ -12,8 +12,8 @@ import {
} from "@mui/x-data-grid"; } from "@mui/x-data-grid";
import React from "react"; import React from "react";
import { FamilyApi, FamilyUser } from "../../api/FamilyApi"; import { FamilyApi, FamilyUser } from "../../api/FamilyApi";
import { useAlert } from "../../context_providers/AlertDialogProvider"; import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
import { useConfirm } from "../../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
import { AsyncWidget } from "../../widgets/AsyncWidget"; import { AsyncWidget } from "../../widgets/AsyncWidget";
import { useUser } from "../../widgets/BaseAuthenticatedPage"; import { useUser } from "../../widgets/BaseAuthenticatedPage";
import { useFamily } from "../../widgets/BaseFamilyRoute"; import { useFamily } from "../../widgets/BaseFamilyRoute";

View File

@ -27,9 +27,9 @@ import { Outlet, useLocation, useParams } from "react-router-dom";
import { Family, FamilyApi } from "../api/FamilyApi"; import { Family, FamilyApi } from "../api/FamilyApi";
import { AsyncWidget } from "./AsyncWidget"; import { AsyncWidget } from "./AsyncWidget";
import { RouterLink } from "./RouterLink"; import { RouterLink } from "./RouterLink";
import { useSnackbar } from "../context_providers/SnackbarProvider"; import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
import { useConfirm } from "../context_providers/ConfirmDialogProvider"; import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
import { useAlert } from "../context_providers/AlertDialogProvider"; import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
interface FamilyContext { interface FamilyContext {
family: Family; family: Family;

View File

@ -0,0 +1,59 @@
// https://github.com/remix-run/react-router/blob/main/examples/navigation-blocking/src/app.tsx
// https://stackoverflow.com/questions/75135147/react-router-dom-v6-useblocker
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React from "react";
import { unstable_useBlocker as useBlocker } from "react-router-dom";
export function ConfirmLeaveWithoutSaveDialog(p: {
shouldBlock: boolean;
}): React.ReactElement {
let blocker = useBlocker(p.shouldBlock);
React.useEffect(() => {
if (blocker.state === "blocked" && !p.shouldBlock) {
blocker.proceed();
}
}, [blocker, p.shouldBlock]);
const cancelNavigation = () => {
blocker.reset?.();
};
const confirmNavigation = () => {
blocker.proceed?.();
};
return (
<Dialog
open={blocker.state === "blocked"}
onClose={cancelNavigation}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
Quitter sans enregistrer ?
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Voulez-vous vraiment quitter cette page sans enregistrer vos
modifications ?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={confirmNavigation as any}>Quitter la page</Button>
<Button onClick={cancelNavigation as any} autoFocus>
Rester sur la page
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -1,7 +1,7 @@
import Brightness7Icon from "@mui/icons-material/Brightness7"; import Brightness7Icon from "@mui/icons-material/Brightness7";
import DarkModeIcon from "@mui/icons-material/DarkMode"; import DarkModeIcon from "@mui/icons-material/DarkMode";
import { IconButton, Tooltip } from "@mui/material"; import { IconButton, Tooltip } from "@mui/material";
import { useDarkTheme } from "../context_providers/DarkThemeProvider"; import { useDarkTheme } from "../hooks/context_providers/DarkThemeProvider";
export function DarkThemeButton(): React.ReactElement { export function DarkThemeButton(): React.ReactElement {
const darkTheme = useDarkTheme(); const darkTheme = useDarkTheme();