Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
2 changed files with 135 additions and 104 deletions
Showing only changes of commit a0feb0e0c9 - Show all commits

View 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,
};
}

View File

@ -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>