243 lines
8.2 KiB
TypeScript
243 lines
8.2 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router-dom";
|
|
import Button from "./Button";
|
|
import Dialog from "./Dialog";
|
|
import DismissibleAlert from "./DismissibleAlert";
|
|
|
|
export type UnsavedNavigationAction = () => void;
|
|
|
|
export type UnsavedChangesRegistration = {
|
|
title?: string;
|
|
message?: string;
|
|
onSave: () => boolean | Promise<boolean>;
|
|
onDiscard?: () => void;
|
|
};
|
|
|
|
export type UnsavedDraftGuardOptions = {
|
|
dirty: boolean;
|
|
title?: string;
|
|
message?: string;
|
|
onSave: () => boolean | Promise<boolean>;
|
|
onDiscard?: () => void;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
type UnsavedChangesContextValue = {
|
|
hasUnsavedChanges: boolean;
|
|
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
|
requestNavigation: (action: UnsavedNavigationAction) => void;
|
|
};
|
|
|
|
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
|
|
|
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|
const navigate = useNavigate();
|
|
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
|
|
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState("");
|
|
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
|
|
const nextRegistrationId = useRef(1);
|
|
const registration = registrations.length ? registrations[registrations.length - 1].registration : null;
|
|
|
|
useEffect(() => {
|
|
registrationRef.current = registration;
|
|
}, [registration]);
|
|
|
|
const hasUnsavedChanges = Boolean(registration);
|
|
|
|
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
|
|
if (!next) return () => undefined;
|
|
const id = nextRegistrationId.current;
|
|
nextRegistrationId.current += 1;
|
|
setRegistrations((current) => [...current, { id, registration: next }]);
|
|
return () => {
|
|
setRegistrations((current) => current.filter((item) => item.id !== id));
|
|
};
|
|
}, []);
|
|
|
|
const proceed = useCallback((action: UnsavedNavigationAction) => {
|
|
setPendingAction(null);
|
|
setSaveError("");
|
|
action();
|
|
}, []);
|
|
|
|
const requestNavigation = useCallback((action: UnsavedNavigationAction) => {
|
|
const active = registrationRef.current;
|
|
if (!active) {
|
|
action();
|
|
return;
|
|
}
|
|
setSaveError("");
|
|
setPendingAction(() => action);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
|
const active = registrationRef.current;
|
|
if (!active) return;
|
|
const message = active.message || "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b";
|
|
event.preventDefault();
|
|
event.returnValue = message;
|
|
return message;
|
|
}
|
|
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function onDocumentClick(event: MouseEvent) {
|
|
if (!registrationRef.current) return;
|
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
|
|
|
const target = event.target as Element | null;
|
|
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
|
if (!anchor) return;
|
|
if (anchor.target && anchor.target !== "_self") return;
|
|
if (anchor.hasAttribute("download")) return;
|
|
if (anchor.getAttribute("href")?.startsWith("#")) return;
|
|
|
|
const destination = new URL(anchor.href, window.location.href);
|
|
const current = new URL(window.location.href);
|
|
if (destination.href === current.href) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
requestNavigation(() => {
|
|
if (destination.origin === current.origin) {
|
|
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
|
|
} else {
|
|
window.location.assign(destination.href);
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", onDocumentClick, true);
|
|
return () => document.removeEventListener("click", onDocumentClick, true);
|
|
}, [navigate, requestNavigation]);
|
|
|
|
async function handleSaveAndLeave() {
|
|
const action = pendingAction;
|
|
const active = registrationRef.current;
|
|
if (!action || !active) return;
|
|
|
|
setSaving(true);
|
|
setSaveError("");
|
|
try {
|
|
const ok = await active.onSave();
|
|
if (!ok) {
|
|
setSaveError("i18n:govoplan-core.the_changes_could_not_be_saved_please_review_the.4615a3c6");
|
|
return;
|
|
}
|
|
proceed(action);
|
|
} catch (err) {
|
|
setSaveError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function handleDiscardAndLeave() {
|
|
const action = pendingAction;
|
|
const active = registrationRef.current;
|
|
if (!action) return;
|
|
active?.onDiscard?.();
|
|
proceed(action);
|
|
}
|
|
|
|
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
|
hasUnsavedChanges,
|
|
registerUnsavedChanges,
|
|
requestNavigation
|
|
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
|
|
|
return (
|
|
<UnsavedChangesContext.Provider value={value}>
|
|
{children}
|
|
{pendingAction && registration &&
|
|
<Dialog
|
|
open
|
|
role="alertdialog"
|
|
title={registration.title ?? "i18n:govoplan-core.unsaved_changes.29267269"}
|
|
className="unsaved-changes-dialog"
|
|
footerClassName="unsaved-changes-actions"
|
|
closeOnBackdrop={!saving}
|
|
closeDisabled={saving}
|
|
onClose={() => setPendingAction(null)}
|
|
footer={
|
|
<>
|
|
<Button onClick={() => setPendingAction(null)} disabled={saving}>i18n:govoplan-core.cancel.77dfd213</Button>
|
|
<Button onClick={handleDiscardAndLeave} disabled={saving}>i18n:govoplan-core.discard.36fff63c</Button>
|
|
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "i18n:govoplan-core.saving.ae7e8875" : "i18n:govoplan-core.save_and_leave.0507824a"}</Button>
|
|
</>
|
|
}>
|
|
|
|
<p>{registration.message ?? "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"}</p>
|
|
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
|
</Dialog>
|
|
}
|
|
</UnsavedChangesContext.Provider>);
|
|
|
|
}
|
|
|
|
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
|
hasUnsavedChanges: false,
|
|
registerUnsavedChanges: () => () => undefined,
|
|
requestNavigation: (action) => action()
|
|
};
|
|
|
|
export function useUnsavedChanges() {
|
|
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
|
|
}
|
|
|
|
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
|
|
const { registerUnsavedChanges } = useUnsavedChanges();
|
|
|
|
useEffect(() => {
|
|
return registerUnsavedChanges(registration);
|
|
}, [registerUnsavedChanges, registration]);
|
|
}
|
|
|
|
export function useUnsavedDraftGuard({
|
|
dirty,
|
|
title = "i18n:govoplan-core.unsaved_changes.29267269",
|
|
message = "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b",
|
|
onSave,
|
|
onDiscard,
|
|
enabled = true
|
|
}: UnsavedDraftGuardOptions) {
|
|
const saveRef = useRef(onSave);
|
|
const discardRef = useRef(onDiscard);
|
|
|
|
useEffect(() => {
|
|
saveRef.current = onSave;
|
|
discardRef.current = onDiscard;
|
|
}, [onDiscard, onSave]);
|
|
|
|
const registration = useMemo<UnsavedChangesRegistration | null>(() => enabled && dirty ? {
|
|
title,
|
|
message,
|
|
onSave: () => saveRef.current(),
|
|
onDiscard: () => discardRef.current?.()
|
|
} : null, [dirty, enabled, message, title]);
|
|
|
|
useRegisterUnsavedChanges(registration);
|
|
}
|
|
|
|
export function useGuardedNavigate(): NavigateFunction {
|
|
const navigate = useNavigate();
|
|
const { requestNavigation } = useUnsavedChanges();
|
|
|
|
return useCallback(((to: To | number, options?: NavigateOptions) => {
|
|
requestNavigation(() => {
|
|
if (typeof to === "number") {
|
|
navigate(to);
|
|
} else {
|
|
navigate(to, options);
|
|
}
|
|
});
|
|
}) as NavigateFunction, [navigate, requestNavigation]);
|
|
}
|