refactor: extract map leave guard
This commit is contained in:
parent
90878a6d7f
commit
a0feb0e0c9
120
frontend/src/hooks/useUnsavedMapLeaveGuard.js
Normal file
120
frontend/src/hooks/useUnsavedMapLeaveGuard.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useBeforeUnload } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function useUnsavedMapLeaveGuard({
|
||||||
|
shouldGuardLeave,
|
||||||
|
navigate,
|
||||||
|
performBackNavigation,
|
||||||
|
}) {
|
||||||
|
const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
|
||||||
|
const guardedClickTargetRef = useRef(null);
|
||||||
|
const allowGuardedClickRef = useRef(false);
|
||||||
|
|
||||||
|
useBeforeUnload(
|
||||||
|
useCallback((event) => {
|
||||||
|
if (!shouldGuardLeave) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
}, [shouldGuardLeave]),
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldGuardLeave) {
|
||||||
|
setPendingLeaveTarget(null);
|
||||||
|
guardedClickTargetRef.current = null;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentClick = (event) => {
|
||||||
|
if (allowGuardedClickRef.current) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.altKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
const button = target?.closest("button");
|
||||||
|
const isSwitchingHousehold = Boolean(
|
||||||
|
button?.classList.contains("household-option") &&
|
||||||
|
!button.classList.contains("create-household-btn") &&
|
||||||
|
!button.closest(".household-option-row.active")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
guardedClickTargetRef.current = button;
|
||||||
|
setPendingLeaveTarget({ type: "click" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = target?.closest("a[href]");
|
||||||
|
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = new URL(anchor.href, window.location.href);
|
||||||
|
if (nextUrl.origin !== window.location.origin) return;
|
||||||
|
|
||||||
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||||
|
if (nextPath === currentPath) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
setPendingLeaveTarget({ type: "path", path: nextPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", handleDocumentClick, true);
|
||||||
|
return () => document.removeEventListener("click", handleDocumentClick, true);
|
||||||
|
}, [shouldGuardLeave]);
|
||||||
|
|
||||||
|
const requestBackNavigation = useCallback(() => {
|
||||||
|
if (shouldGuardLeave) {
|
||||||
|
setPendingLeaveTarget({ type: "back" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performBackNavigation();
|
||||||
|
}, [performBackNavigation, shouldGuardLeave]);
|
||||||
|
|
||||||
|
const clearPendingLeave = useCallback(() => {
|
||||||
|
guardedClickTargetRef.current = null;
|
||||||
|
setPendingLeaveTarget(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmPendingLeave = useCallback(() => {
|
||||||
|
const target = pendingLeaveTarget;
|
||||||
|
setPendingLeaveTarget(null);
|
||||||
|
if (target?.type === "click") {
|
||||||
|
const guardedClickTarget = guardedClickTargetRef.current;
|
||||||
|
guardedClickTargetRef.current = null;
|
||||||
|
if (guardedClickTarget) {
|
||||||
|
allowGuardedClickRef.current = true;
|
||||||
|
guardedClickTarget.click();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
allowGuardedClickRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target?.type === "path" && target.path) {
|
||||||
|
navigate(target.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performBackNavigation();
|
||||||
|
}, [navigate, pendingLeaveTarget, performBackNavigation]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingLeaveTarget,
|
||||||
|
requestBackNavigation,
|
||||||
|
clearPendingLeave,
|
||||||
|
confirmPendingLeave,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useBeforeUnload, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
createBlankLocationMap,
|
createBlankLocationMap,
|
||||||
createLocationMapFromZones,
|
createLocationMapFromZones,
|
||||||
@ -17,6 +17,7 @@ import LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
|||||||
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
||||||
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
|
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
|
||||||
import useActionToast from "../hooks/useActionToast";
|
import useActionToast from "../hooks/useActionToast";
|
||||||
|
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
|
||||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAP_FILTERS,
|
DEFAULT_MAP_FILTERS,
|
||||||
@ -73,12 +74,9 @@ export default function LocationMapManager() {
|
|||||||
const [future, setFuture] = useState([]);
|
const [future, setFuture] = useState([]);
|
||||||
const [dragState, setDragState] = useState(null);
|
const [dragState, setDragState] = useState(null);
|
||||||
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
|
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
|
||||||
const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
|
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const toastRef = useRef(toast);
|
const toastRef = useRef(toast);
|
||||||
const loadRequestRef = useRef(0);
|
const loadRequestRef = useRef(0);
|
||||||
const guardedClickTargetRef = useRef(null);
|
|
||||||
const allowGuardedClickRef = useRef(false);
|
|
||||||
|
|
||||||
const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
|
const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
|
||||||
const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
|
const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
|
||||||
@ -134,71 +132,6 @@ export default function LocationMapManager() {
|
|||||||
[activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width]
|
[activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width]
|
||||||
);
|
);
|
||||||
|
|
||||||
useBeforeUnload(
|
|
||||||
useCallback((event) => {
|
|
||||||
if (!shouldGuardLeave) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "";
|
|
||||||
}, [shouldGuardLeave]),
|
|
||||||
{ capture: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldGuardLeave) {
|
|
||||||
setPendingLeaveTarget(null);
|
|
||||||
guardedClickTargetRef.current = null;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDocumentClick = (event) => {
|
|
||||||
if (allowGuardedClickRef.current) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.defaultPrevented ||
|
|
||||||
event.button !== 0 ||
|
|
||||||
event.altKey ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
|
||||||
const button = target?.closest("button");
|
|
||||||
const isSwitchingHousehold = Boolean(
|
|
||||||
button?.classList.contains("household-option") &&
|
|
||||||
!button.classList.contains("create-household-btn") &&
|
|
||||||
!button.closest(".household-option-row.active")
|
|
||||||
);
|
|
||||||
if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
guardedClickTargetRef.current = button;
|
|
||||||
setPendingLeaveTarget({ type: "click" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anchor = target?.closest("a[href]");
|
|
||||||
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUrl = new URL(anchor.href, window.location.href);
|
|
||||||
if (nextUrl.origin !== window.location.origin) return;
|
|
||||||
|
|
||||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
||||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
|
||||||
if (nextPath === currentPath) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
setPendingLeaveTarget({ type: "path", path: nextPath });
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("click", handleDocumentClick, true);
|
|
||||||
return () => document.removeEventListener("click", handleDocumentClick, true);
|
|
||||||
}, [shouldGuardLeave]);
|
|
||||||
|
|
||||||
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => {
|
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => {
|
||||||
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
|
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
|
||||||
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
|
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
|
||||||
@ -618,35 +551,16 @@ export default function LocationMapManager() {
|
|||||||
navigate("/");
|
navigate("/");
|
||||||
}, [navigate, returnPath]);
|
}, [navigate, returnPath]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const {
|
||||||
if (shouldGuardLeave) {
|
pendingLeaveTarget,
|
||||||
setPendingLeaveTarget({ type: "back" });
|
requestBackNavigation,
|
||||||
return;
|
clearPendingLeave,
|
||||||
}
|
confirmPendingLeave,
|
||||||
performBackNavigation();
|
} = useUnsavedMapLeaveGuard({
|
||||||
}, [performBackNavigation, shouldGuardLeave]);
|
shouldGuardLeave,
|
||||||
|
navigate,
|
||||||
const confirmPendingLeave = useCallback(() => {
|
performBackNavigation,
|
||||||
const target = pendingLeaveTarget;
|
});
|
||||||
setPendingLeaveTarget(null);
|
|
||||||
if (target?.type === "click") {
|
|
||||||
const guardedClickTarget = guardedClickTargetRef.current;
|
|
||||||
guardedClickTargetRef.current = null;
|
|
||||||
if (guardedClickTarget) {
|
|
||||||
allowGuardedClickRef.current = true;
|
|
||||||
guardedClickTarget.click();
|
|
||||||
window.setTimeout(() => {
|
|
||||||
allowGuardedClickRef.current = false;
|
|
||||||
}, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (target?.type === "path" && target.path) {
|
|
||||||
navigate(target.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
performBackNavigation();
|
|
||||||
}, [navigate, pendingLeaveTarget, performBackNavigation]);
|
|
||||||
|
|
||||||
if (!hasLoaded || householdLoading || loading) {
|
if (!hasLoaded || householdLoading || loading) {
|
||||||
return (
|
return (
|
||||||
@ -667,7 +581,7 @@ export default function LocationMapManager() {
|
|||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="location-map-page">
|
<div className="location-map-page">
|
||||||
<LocationMapTopbar location={location} status="Load Error" onBack={handleBack} />
|
<LocationMapTopbar location={location} status="Load Error" onBack={requestBackNavigation} />
|
||||||
<main className="location-map-workspace">
|
<main className="location-map-workspace">
|
||||||
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
||||||
<h2>Map Unavailable</h2>
|
<h2>Map Unavailable</h2>
|
||||||
@ -689,7 +603,7 @@ export default function LocationMapManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="location-map-page">
|
<div className="location-map-page">
|
||||||
<LocationMapTopbar location={location} status={status} onBack={handleBack} />
|
<LocationMapTopbar location={location} status={status} onBack={requestBackNavigation} />
|
||||||
|
|
||||||
<main className="location-map-workspace">
|
<main className="location-map-workspace">
|
||||||
{hasAnyMap && mode !== "setup" ? (
|
{hasAnyMap && mode !== "setup" ? (
|
||||||
@ -805,10 +719,7 @@ export default function LocationMapManager() {
|
|||||||
title="Leave with unsaved changes?"
|
title="Leave with unsaved changes?"
|
||||||
description="Your draft edits are only on this screen right now. Save the draft before leaving if you want to keep them."
|
description="Your draft edits are only on this screen right now. Save the draft before leaving if you want to keep them."
|
||||||
confirmLabel="Leave Map"
|
confirmLabel="Leave Map"
|
||||||
onClose={() => {
|
onClose={clearPendingLeave}
|
||||||
guardedClickTargetRef.current = null;
|
|
||||||
setPendingLeaveTarget(null);
|
|
||||||
}}
|
|
||||||
onConfirm={confirmPendingLeave}
|
onConfirm={confirmPendingLeave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user