Files
govoplan-core/webui/src/components/UnsavedChangesGuard.tsx

254 lines
8.6 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;
/**
* Route an explicit Discard button through the same confirmation used for
* dirty navigation. The action runs after either saving or discarding.
*/
requestDiscard: (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);
}, []);
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
requestNavigation(action);
}, [requestNavigation]);
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,
requestDiscard
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, 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(),
requestDiscard: (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]);
}