From 3f6eade576531c70b5c67b83fcc98a5b191c5847 Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 08:16:44 -0700 Subject: [PATCH] refactor: extract map draft state hook --- .../src/hooks/useLocationMapDraftState.js | 142 +++++++++++++ frontend/src/pages/LocationMapManager.jsx | 191 ++++-------------- 2 files changed, 185 insertions(+), 148 deletions(-) create mode 100644 frontend/src/hooks/useLocationMapDraftState.js diff --git a/frontend/src/hooks/useLocationMapDraftState.js b/frontend/src/hooks/useLocationMapDraftState.js new file mode 100644 index 0000000..63e62f3 --- /dev/null +++ b/frontend/src/hooks/useLocationMapDraftState.js @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getLocationMap } from "../api/locationMaps"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; +import { DEFAULT_MAP_FILTERS, DEFAULT_MAP_SIZE, mapForMode, normalizeMapObject, objectsForMode } from "../lib/locationMapUtils"; + +const DEFAULT_DRAFT_MAP = { + name: "Store Map", + width: DEFAULT_MAP_SIZE.width, + height: DEFAULT_MAP_SIZE.height, +}; + +export default function useLocationMapDraftState({ + activeHousehold, + hasLoaded, + householdLoading, + locationId, + toast, +}) { + const [mapState, setMapState] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [saving, setSaving] = useState(false); + const [savingAction, setSavingAction] = useState(null); + const [mode, setMode] = useState("setup"); + const [editorTool, setEditorTool] = useState("pan"); + const [previewDraft, setPreviewDraft] = useState(false); + const [mapDraft, setMapDraft] = useState(DEFAULT_DRAFT_MAP); + const [objects, setObjects] = useState([]); + const [selectedObjectKey, setSelectedObjectKey] = useState(null); + const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS); + const [layersOpen, setLayersOpen] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [zoom, setZoom] = useState(0.75); + const [history, setHistory] = useState([]); + const [future, setFuture] = useState([]); + const [dragState, setDragState] = useState(null); + const toastRef = useRef(toast); + const loadRequestRef = useRef(0); + + const resetDraftState = useCallback(() => { + setObjects([]); + setSelectedObjectKey(null); + setHistory([]); + setFuture([]); + setHasUnsavedChanges(false); + setMapDraft(DEFAULT_DRAFT_MAP); + }, []); + + const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => { + const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft); + const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft); + if (!nextMap) return; + + setMapDraft({ + name: nextMap.name || "Store Map", + width: nextMap.width || DEFAULT_MAP_SIZE.width, + height: nextMap.height || DEFAULT_MAP_SIZE.height, + }); + setObjects(nextObjects.map(normalizeMapObject)); + setSelectedObjectKey(null); + setHistory([]); + setFuture([]); + setHasUnsavedChanges(false); + }, []); + + const loadMap = useCallback(async () => { + const requestId = loadRequestRef.current + 1; + loadRequestRef.current = requestId; + + if (!activeHousehold?.id || !locationId) { + setLoading(false); + return; + } + + setLoading(true); + setLoadError(null); + try { + const response = await getLocationMap(activeHousehold.id, locationId); + if (loadRequestRef.current !== requestId) return; + + const nextState = response.data; + const nextCanManage = Boolean( + nextState.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role) + ); + setMapState(nextState); + setEditorTool("pan"); + + if (!nextState.draft_map && !nextState.published_map) { + setMode("setup"); + setPreviewDraft(false); + resetDraftState(); + return; + } + + if (nextState.published_map) { + setMode("view"); + setPreviewDraft(false); + syncMapDraftFromState(nextState, "view", false); + } else if (nextState.draft_map && nextCanManage) { + setMode("setup"); + setPreviewDraft(true); + syncMapDraftFromState(nextState, "edit", true); + } else { + setMode("setup"); + setPreviewDraft(false); + resetDraftState(); + } + } catch (error) { + if (loadRequestRef.current !== requestId) return; + + const message = getApiErrorMessage(error, "Failed to load map"); + toastRef.current.error("Load map failed", message); + setMapState(null); + resetDraftState(); + setLoadError(message); + } finally { + if (loadRequestRef.current === requestId) { + setLoading(false); + } + } + }, [activeHousehold?.id, activeHousehold?.role, locationId, resetDraftState, syncMapDraftFromState]); + + useEffect(() => { toastRef.current = toast; }, [toast]); + + useEffect(() => { + if (!householdLoading && hasLoaded) { + loadMap(); + } + }, [hasLoaded, householdLoading, loadMap]); + + const beginSaving = useCallback((action) => { setSavingAction(action); setSaving(true); }, []); + + const endSaving = useCallback(() => { setSaving(false); setSavingAction(null); }, []); + + return { + beginSaving, dragState, editorTool, endSaving, filters, future, hasUnsavedChanges, history, layersOpen, + loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving, + savingAction, selectedObjectKey, setDragState, setEditorTool, setFilters, setFuture, + setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects, + setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom, + }; +} diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index 409de94..e6dcbb2 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -3,7 +3,6 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { createBlankLocationMap, createLocationMapFromZones, - getLocationMap, publishLocationMapDraft, saveLocationMapDraft, } from "../api/locationMaps"; @@ -17,10 +16,10 @@ import LocationMapToolbar from "../components/maps/LocationMapToolbar"; import LocationMapTopbar from "../components/maps/LocationMapTopbar"; import ConfirmSlideModal from "../components/modals/ConfirmSlideModal"; import useActionToast from "../hooks/useActionToast"; +import useLocationMapDraftState from "../hooks/useLocationMapDraftState"; import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard"; import getApiErrorMessage from "../lib/getApiErrorMessage"; import { - DEFAULT_MAP_FILTERS, DEFAULT_MAP_SIZE, clampMapZoom, clampObjectToMap, @@ -31,9 +30,7 @@ import { itemMatchesMapFilters, itemsForZone, mapForMode, - normalizeMapObject, objectZoneId, - objectsForMode, prepareObjectsForSave, unmappedItems, } from "../lib/locationMapUtils"; @@ -51,32 +48,51 @@ export default function LocationMapManager() { const { activeHousehold, loading: householdLoading, hasLoaded } = useContext(HouseholdContext); const { stores, activeStore, setActiveStore } = useContext(StoreContext); - const [mapState, setMapState] = useState(null); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [saving, setSaving] = useState(false); - const [savingAction, setSavingAction] = useState(null); - const [mode, setMode] = useState("setup"); - const [editorTool, setEditorTool] = useState("pan"); - const [previewDraft, setPreviewDraft] = useState(false); - const [mapDraft, setMapDraft] = useState({ - name: "Store Map", - width: DEFAULT_MAP_SIZE.width, - height: DEFAULT_MAP_SIZE.height, + const { + beginSaving, + dragState, + editorTool, + endSaving, + filters, + future, + hasUnsavedChanges, + history, + layersOpen, + loadError, + loadMap, + loading, + mapDraft, + mapState, + mode, + objects, + previewDraft, + saving, + savingAction, + selectedObjectKey, + setDragState, + setEditorTool, + setFilters, + setFuture, + setHasUnsavedChanges, + setHistory, + setLayersOpen, + setMapState, + setMode, + setObjects, + setPreviewDraft, + setSelectedObjectKey, + setZoom, + syncMapDraftFromState, + zoom, + } = useLocationMapDraftState({ + activeHousehold, + hasLoaded, + householdLoading, + locationId, + toast, }); - const [objects, setObjects] = useState([]); - const [selectedObjectKey, setSelectedObjectKey] = useState(null); - const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS); - const [layersOpen, setLayersOpen] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [zoom, setZoom] = useState(0.75); - const [history, setHistory] = useState([]); - const [future, setFuture] = useState([]); - const [dragState, setDragState] = useState(null); const [pendingDeleteObject, setPendingDeleteObject] = useState(null); const svgRef = useRef(null); - const toastRef = useRef(toast); - const loadRequestRef = useRef(0); const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId)); const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)); @@ -132,117 +148,6 @@ export default function LocationMapManager() { [activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width] ); - const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => { - const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft); - const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft); - if (!nextMap) return; - - setMapDraft({ - name: nextMap.name || "Store Map", - width: nextMap.width || DEFAULT_MAP_SIZE.width, - height: nextMap.height || DEFAULT_MAP_SIZE.height, - }); - setObjects(nextObjects.map(normalizeMapObject)); - setSelectedObjectKey(null); - setHistory([]); - setFuture([]); - setHasUnsavedChanges(false); - }, []); - - const loadMap = useCallback(async () => { - const requestId = loadRequestRef.current + 1; - loadRequestRef.current = requestId; - - if (!activeHousehold?.id || !locationId) { - setLoading(false); - return; - } - - setLoading(true); - setLoadError(null); - try { - const response = await getLocationMap(activeHousehold.id, locationId); - if (loadRequestRef.current !== requestId) return; - - const nextState = response.data; - const nextCanManage = Boolean( - nextState.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role) - ); - setMapState(nextState); - - if (!nextState.draft_map && !nextState.published_map) { - setMode("setup"); - setEditorTool("pan"); - setPreviewDraft(false); - setObjects([]); - setHasUnsavedChanges(false); - setMapDraft({ - name: "Store Map", - width: DEFAULT_MAP_SIZE.width, - height: DEFAULT_MAP_SIZE.height, - }); - return; - } - - if (nextState.published_map) { - setMode("view"); - setEditorTool("pan"); - setPreviewDraft(false); - } else if (nextState.draft_map && nextCanManage) { - setMode("setup"); - setEditorTool("pan"); - setPreviewDraft(true); - } else { - setMode("setup"); - setEditorTool("pan"); - setPreviewDraft(false); - setObjects([]); - setSelectedObjectKey(null); - setHistory([]); - setFuture([]); - setHasUnsavedChanges(false); - setMapDraft({ - name: "Store Map", - width: DEFAULT_MAP_SIZE.width, - height: DEFAULT_MAP_SIZE.height, - }); - return; - } - - syncMapDraftFromState( - nextState, - nextState.published_map ? "view" : "edit", - !nextState.published_map - ); - } catch (error) { - if (loadRequestRef.current !== requestId) return; - - const message = getApiErrorMessage(error, "Failed to load map"); - toastRef.current.error("Load map failed", message); - setMapState(null); - setObjects([]); - setSelectedObjectKey(null); - setHistory([]); - setFuture([]); - setHasUnsavedChanges(false); - setLoadError(message); - } finally { - if (loadRequestRef.current === requestId) { - setLoading(false); - } - } - }, [activeHousehold?.id, activeHousehold?.role, locationId, syncMapDraftFromState]); - - useEffect(() => { - toastRef.current = toast; - }, [toast]); - - useEffect(() => { - if (!householdLoading && hasLoaded) { - loadMap(); - } - }, [hasLoaded, householdLoading, loadMap]); - useEffect(() => { const matchingLocation = stores.find((store) => String(store.id) === String(locationId)); if (matchingLocation && String(activeStore?.id) !== String(matchingLocation.id)) { @@ -283,16 +188,6 @@ export default function LocationMapManager() { }); }; - const beginSaving = (action) => { - setSavingAction(action); - setSaving(true); - }; - - const endSaving = () => { - setSaving(false); - setSavingAction(null); - }; - const handleCreateBlank = async () => { if (!activeHousehold?.id || !locationId) return; beginSaving("create-blank");