diff --git a/front/src/components/BoxReconciliationOverlay/components/BoxReconciliationAccordion.tsx b/front/src/components/BoxReconciliationOverlay/components/BoxReconciliationAccordion.tsx index 6a6c232ae4..d3ce6b4075 100644 --- a/front/src/components/BoxReconciliationOverlay/components/BoxReconciliationAccordion.tsx +++ b/front/src/components/BoxReconciliationOverlay/components/BoxReconciliationAccordion.tsx @@ -15,7 +15,7 @@ import { RiQuestionFill } from "react-icons/ri"; import { ILocationData, IProductWithSizeRangeData } from "./BoxReconciliationView"; import { MatchProductsFormData, MatchProductsForm } from "./MatchProductsForm"; import { IReceiveLocationFormData, ReceiveLocationForm } from "./ReceiveLocationForm"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { reconciliationMatchProductAtom, reconciliationReceiveLocationAtom, @@ -55,27 +55,25 @@ export function BoxReconcilationAccordion({ onBoxUndelivered, onBoxDelivered, }: IBoxReconcilationAccordionProps) { - const [reconciliationMatchProductCache, setReconciliationMatchProductCache] = useAtom( - reconciliationMatchProductAtom, - ); - const cachedReconciliationMatchProduct = useAtomValue(reconciliationMatchProductAtom); + const reconciliationMatchProductCache = useAtomValue(reconciliationMatchProductAtom); + const setReconciliationMatchProductCache = useSetAtom(reconciliationMatchProductAtom); const setReconciliationReceiveLocationCache = useSetAtom(reconciliationReceiveLocationAtom); // source side const sourceProductId = shipmentDetail.sourceProduct?.id; const sourceSizeId = shipmentDetail.sourceSize?.id; const isSourceProductInCache = - sourceProductId && !!cachedReconciliationMatchProduct[sourceProductId]; + sourceProductId && !!reconciliationMatchProductCache[sourceProductId]; // target side const isTargetProductAutoMatched = !!shipmentDetail?.autoMatchingTargetProduct; const targetProductName = isSourceProductInCache - ? cachedReconciliationMatchProduct[sourceProductId].productId.label + ? reconciliationMatchProductCache[sourceProductId].productId.label : isTargetProductAutoMatched && shipmentDetail?.autoMatchingTargetProduct ? shipmentDetail.autoMatchingTargetProduct.name : undefined; const targetProductId = isSourceProductInCache - ? cachedReconciliationMatchProduct[sourceProductId].productId.value + ? reconciliationMatchProductCache[sourceProductId].productId.value : isTargetProductAutoMatched && shipmentDetail?.autoMatchingTargetProduct ? shipmentDetail.autoMatchingTargetProduct.id : undefined; @@ -86,12 +84,12 @@ export function BoxReconcilationAccordion({ return sourceSizeId && possibleSizeIds.includes(sourceSizeId); }); const targetSizeName = isSourceProductInCache - ? cachedReconciliationMatchProduct[sourceProductId].sizeId.label + ? reconciliationMatchProductCache[sourceProductId].sizeId.label : isTargetProductAutoMatched && isSourceSizeInRangeOfTargetProduct && shipmentDetail?.sourceSize ? shipmentDetail.sourceSize.label : undefined; const targetSizeId = isSourceProductInCache - ? cachedReconciliationMatchProduct[sourceProductId].sizeId.value + ? reconciliationMatchProductCache[sourceProductId].sizeId.value : isTargetProductAutoMatched && isSourceSizeInRangeOfTargetProduct && shipmentDetail?.sourceSize ? shipmentDetail.sourceSize.id : undefined; @@ -187,9 +185,10 @@ export function BoxReconcilationAccordion({ setAccordionIndex(1); if (shipmentDetail.sourceProduct?.id) { - reconciliationMatchProductCache[shipmentDetail.sourceProduct.id] = - matchedProductsFormData; - setReconciliationMatchProductCache(reconciliationMatchProductCache); + setReconciliationMatchProductCache({ + ...reconciliationMatchProductCache, + [shipmentDetail.sourceProduct.id]: matchedProductsFormData, + }); } setProductFormData({ diff --git a/front/src/components/BoxReconciliationOverlay/components/MatchProductsForm.tsx b/front/src/components/BoxReconciliationOverlay/components/MatchProductsForm.tsx index da13328a7e..3dc4b35231 100644 --- a/front/src/components/BoxReconciliationOverlay/components/MatchProductsForm.tsx +++ b/front/src/components/BoxReconciliationOverlay/components/MatchProductsForm.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Box, Button, Flex, Text, Wrap, WrapItem } from "@chakra-ui/react"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { groupBy } from "lodash"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { BiSubdirectoryRight } from "react-icons/bi"; import { BsFillCheckCircleFill } from "react-icons/bs"; import { IProductWithSizeRangeData } from "./BoxReconciliationView"; @@ -69,7 +69,6 @@ export function MatchProductsForm({ const { handleSubmit, control, - watch, resetField, formState: { errors, isSubmitting }, } = useForm({ @@ -78,39 +77,32 @@ export function MatchProductsForm({ }); // needed for updating size select field for new product - const productId = watch("productId"); - const sizeId = watch("sizeId"); + const productId = useWatch({ control, name: "productId" }); + const sizeId = useWatch({ control, name: "sizeId" }); const productRef = useRef(); // sizes reset depending on selected product - const [sizesOptionsForCurrentProduct, setSizesOptionsForCurrentProduct] = useState< - IDropdownOption[] - >([]); + const sizesOptionsForCurrentProduct: IDropdownOption[] = useMemo( + () => + productId + ? (productAndSizesData + .find((p) => p.id === productId.value) + ?.sizeRange?.sizes?.map((s) => ({ label: s.label, value: s.id })) ?? []) + : [], + [productId, productAndSizesData], + ); useEffect(() => { - if (productId != null) { - const productAndSizeDataForCurrentProduct = productAndSizesData.find( - (p) => p.id === productId.value, - ); - const prepSizesOptionsForCurrentProduct = - productAndSizeDataForCurrentProduct?.sizeRange?.sizes?.map((s) => ({ - label: s.label, - value: s.id, - })) || []; - setSizesOptionsForCurrentProduct(() => prepSizesOptionsForCurrentProduct); - - // Reset size if the product reference is different than the currently selected product - if (productRef.current !== productId.value) { - productRef.current = productId.value; - // if there is only one option select it directly - if (prepSizesOptionsForCurrentProduct.length === 1) { - resetField("sizeId", { defaultValue: prepSizesOptionsForCurrentProduct[0] }); - } else { - resetField("sizeId", { defaultValue: undefined }); - } + if (productId != null && productRef.current !== productId.value) { + productRef.current = productId.value; + // if there is only one option select it directly + if (sizesOptionsForCurrentProduct.length === 1) { + resetField("sizeId", { defaultValue: sizesOptionsForCurrentProduct[0] }); + } else { + resetField("sizeId", { defaultValue: undefined }); } } - }, [productId, productAndSizesData, resetField]); + }, [productId, sizesOptionsForCurrentProduct, resetField]); // Option Preparations for select fields const productsGroupedByCategory: Record = groupBy( diff --git a/front/src/components/BoxReconciliationOverlay/components/ReceiveLocationForm.tsx b/front/src/components/BoxReconciliationOverlay/components/ReceiveLocationForm.tsx index c2354005f8..50459d35ef 100644 --- a/front/src/components/BoxReconciliationOverlay/components/ReceiveLocationForm.tsx +++ b/front/src/components/BoxReconciliationOverlay/components/ReceiveLocationForm.tsx @@ -2,7 +2,7 @@ import { Button, Flex } from "@chakra-ui/react"; import { zodResolver } from "@hookform/resolvers/zod"; import SelectField from "components/Form/SelectField"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { BsFillCheckCircleFill } from "react-icons/bs"; import { FaWarehouse } from "react-icons/fa"; import { z } from "zod"; @@ -57,7 +57,6 @@ export function ReceiveLocationForm({ // react-hook-form const { control, - watch, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ @@ -65,7 +64,7 @@ export function ReceiveLocationForm({ defaultValues, }); - const locationId = watch("locationId"); + const locationId = useWatch({ control, name: "locationId" }); useEffect(() => { if ( diff --git a/front/src/components/Walkthrough/desktop/TourOverlay.tsx b/front/src/components/Walkthrough/desktop/TourOverlay.tsx index 79db980c74..fd5d9c8ac1 100644 --- a/front/src/components/Walkthrough/desktop/TourOverlay.tsx +++ b/front/src/components/Walkthrough/desktop/TourOverlay.tsx @@ -8,7 +8,6 @@ import { STATUS, Step, TooltipRenderProps, - TourData, } from "react-joyride"; import { Box, Button, Flex, IconButton, Progress, Text } from "@chakra-ui/react"; import { BiChevronLeft } from "react-icons/bi"; @@ -34,8 +33,7 @@ export const PATHS: Record = { // Chakra v2 generates the AccordionButton's id as `accordion-button-{AccordionItem id}`, // so we target it directly rather than querying inside the AccordionItem element. function makeExpandGroupHook(groupName: string): BeforeHook { - // eslint-disable-next-line no-unused-vars - return async (_data: TourData) => { + return async () => { const groupId = nameToNavId(groupName); const btn = document.getElementById(`accordion-button-${groupId}`) as HTMLButtonElement | null; if (!btn || btn.getAttribute("aria-expanded") === "true") return; @@ -161,14 +159,13 @@ function TourOverlay() { backToPathSelection, } = useWalkthrough(); const visiblePaths = useVisiblePaths(); + const [stepIndex, setStepIndex] = useState(0); const [run, setRun] = useState(false); const isActive = isWalkthroughActive && currentStep === "tour" && activePath != null; const pathDef = activePath ? PATHS[activePath] : null; - // Compute the step list once when the path changes so Joyride never receives a - // new array reference on every render (which would reset its internal state). - // buildJoyrideSteps also filters out steps whose targets are absent from the DOM. + // eslint-disable-next-line react-hooks/exhaustive-deps const steps = useMemo(() => (pathDef ? buildJoyrideSteps(pathDef.steps) : []), [activePath]); const totalSteps = steps.length; @@ -180,24 +177,29 @@ function TourOverlay() { [activePath, visiblePaths, completedPaths], ); - // Reset step index whenever the active path changes useEffect(() => { - if (!isActive) { - setRun(false); - return; - } - setStepIndex(0); + if (!isActive) return; + // Small delay to allow DOM to settle before joyride starts const t = setTimeout(() => setRun(true), 100); - return () => clearTimeout(t); - }, [isActive, activePath]); + return () => { + clearTimeout(t); + setRun(false); // Clean up state when component unmounts or path changes + }; + }, [isActive]); // Only depend on isActive starting/stopping const handleEvent = useCallback( (data: EventData) => { const { status, action, index, type } = data; - if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { + // Helper to reset the tour state completely + const resetTourState = () => { setRun(false); + setStepIndex(0); + }; + + if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { + resetTourState(); if (activePath) completePath(activePath); return; } @@ -206,11 +208,9 @@ function TourOverlay() { if (type === EVENTS.TARGET_NOT_FOUND) { const nextIndex = index + 1; if (nextIndex < steps.length) { - // Skip to next step if available setStepIndex(nextIndex); } else { - // No more steps, go back to path selection - setRun(false); + resetTourState(); backToPathSelection(); } return; @@ -222,7 +222,7 @@ function TourOverlay() { } else if (action === ACTIONS.PREV) { setStepIndex(Math.max(0, index - 1)); } else if (action === ACTIONS.CLOSE || action === ACTIONS.SKIP) { - setRun(false); + resetTourState(); backToPathSelection(); } } diff --git a/front/src/components/Walkthrough/desktop/WalkthroughContext.tsx b/front/src/components/Walkthrough/desktop/WalkthroughContext.tsx index d1da295cbc..72385b2394 100644 --- a/front/src/components/Walkthrough/desktop/WalkthroughContext.tsx +++ b/front/src/components/Walkthrough/desktop/WalkthroughContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; import { useAuth0 } from "@auth0/auth0-react"; import { PathId } from "./paths/types"; import { useVisiblePaths } from "./useVisiblePaths"; @@ -66,22 +66,36 @@ function saveState(userId: string, state: WalkthroughState) { export function WalkthroughProvider({ children }: { children: React.ReactNode }) { const { user } = useAuth0(); const userId = user?.sub ?? "anonymous"; + const visiblePaths = useVisiblePaths(); + + const [prevUserId, setPrevUserId] = useState(userId); + + const [isWalkthroughActive, setIsWalkthroughActive] = useState(() => { + const state = loadState(userId); + return !state.hasSeenWelcome; + }); + + const [currentStep, setCurrentStep] = useState(() => { + const state = loadState(userId); + return !state.hasSeenWelcome ? "welcome" : "pathSelection"; + }); + + const [completedPaths, setCompletedPaths] = useState>(() => { + const state = loadState(userId); + return new Set(state.completedPaths); + }); - const [isWalkthroughActive, setIsWalkthroughActive] = useState(false); - const [currentStep, setCurrentStep] = useState("welcome"); const [activePath, setActivePath] = useState(null); - const [completedPaths, setCompletedPaths] = useState>(new Set()); - const visiblePaths = useVisiblePaths(); - // Load persisted state and auto-show welcome for new users - useEffect(() => { + if (userId !== prevUserId) { + setPrevUserId(userId); + const state = loadState(userId); setCompletedPaths(new Set(state.completedPaths)); - if (!state.hasSeenWelcome) { - setIsWalkthroughActive(true); - setCurrentStep("welcome"); - } - }, [userId]); + setIsWalkthroughActive(!state.hasSeenWelcome); + setCurrentStep(!state.hasSeenWelcome ? "welcome" : "pathSelection"); + setActivePath(null); + } const persistCompletedPaths = useCallback( (paths: Set) => { @@ -93,11 +107,9 @@ export function WalkthroughProvider({ children }: { children: React.ReactNode }) const goToPathSelection = useCallback(() => { setIsWalkthroughActive(true); - // Mark welcome as seen const state = loadState(userId); saveState(userId, { ...state, hasSeenWelcome: true }); - // short-cut to only path if (visiblePaths.length === 1) { setActivePath(visiblePaths[0].id); setCurrentStep("tour"); @@ -110,7 +122,6 @@ export function WalkthroughProvider({ children }: { children: React.ReactNode }) const closeWalkthrough = useCallback(() => { setIsWalkthroughActive(false); setActivePath(null); - // Mark welcome as seen when user skips/closes const state = loadState(userId); saveState(userId, { ...state, hasSeenWelcome: true }); }, [userId]); diff --git a/front/src/components/Walkthrough/mobile/MobileWalkthroughContext.tsx b/front/src/components/Walkthrough/mobile/MobileWalkthroughContext.tsx index be7cdcb8a5..f0a274a623 100644 --- a/front/src/components/Walkthrough/mobile/MobileWalkthroughContext.tsx +++ b/front/src/components/Walkthrough/mobile/MobileWalkthroughContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; import { useAuth0 } from "@auth0/auth0-react"; import { JWT_ROLE } from "utils/constants"; import { isCoordinatorOrAbove } from "../roles"; @@ -63,16 +63,13 @@ export function MobileWalkthroughProvider({ children }: { children: React.ReactN const roles: string[] = user?.[JWT_ROLE] ?? []; const isCoordinator = isCoordinatorOrAbove(roles); - const [step, setStep] = useState("idle"); - const [slideIndex, setSlideIndex] = useState(0); - - // Auto-show welcome for first-time mobile users - useEffect(() => { + // Initialize state lazily based on local storage + const [step, setStep] = useState(() => { const state = loadState(userId); - if (!state.hasSeenWelcome) { - setStep("welcome"); - } - }, [userId]); + return state.hasSeenWelcome ? "idle" : "welcome"; + }); + + const [slideIndex, setSlideIndex] = useState(0); const markSeen = useCallback(() => { saveState(userId, { hasSeenWelcome: true }); diff --git a/front/src/views/Box/BoxView.tsx b/front/src/views/Box/BoxView.tsx index 4c97ca8f89..bec82842b3 100644 --- a/front/src/views/Box/BoxView.tsx +++ b/front/src/views/Box/BoxView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useMutation, useQuery, NetworkStatus } from "@apollo/client"; import { graphql } from "gql.tada"; import { @@ -211,7 +211,6 @@ function BTBox() { const { createToast } = useNotification(); const labelIdentifier = useParams<{ labelIdentifier: string }>().labelIdentifier!; const baseId = useAtomValue(selectedBaseIdAtom); - const [currentBoxState, setCurrentState] = useState(); const { isOpen: isHistoryOpen, onOpen: onHistoryOpen, onClose: onHistoryClose } = useDisclosure(); const { assignBoxesToShipment, @@ -232,10 +231,6 @@ function BTBox() { }, ); - const boxInTransit = currentBoxState - ? ["Receiving", "MarkedForShipment", "InTransit"].includes(currentBoxState) - : false; - // map over each box HistoryEntry to compile its timeline records const boxLogs: ITimelineEntry[] = allData.data?.box?.history?.flatMap((histories) => _.compact([ @@ -315,8 +310,11 @@ function BTBox() { const boxData = allData.data?.box; + const boxInTransit = boxData?.state + ? ["Receiving", "MarkedForShipment", "InTransit"].includes(boxData.state) + : false; + useEffect(() => { - setCurrentState(boxData?.state); const shipmentId = boxData?.shipmentDetail?.shipment.id; // open reconciliation overlay if the box state is receiving and if we're on the receiving side if ( diff --git a/front/src/views/Box/components/AddItemsToBoxOverlay.tsx b/front/src/views/Box/components/AddItemsToBoxOverlay.tsx index 4e8a3d5673..7abe1624ed 100644 --- a/front/src/views/Box/components/AddItemsToBoxOverlay.tsx +++ b/front/src/views/Box/components/AddItemsToBoxOverlay.tsx @@ -10,7 +10,7 @@ import { ModalOverlay, Spacer, } from "@chakra-ui/react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { IChangeNumberOfItemsBoxData } from "../BoxView"; import { NumberField } from "@boxtribute/shared-components"; @@ -30,14 +30,13 @@ function AddItemsToBoxOverlay({ const { control, handleSubmit, - watch, formState: { isSubmitting, errors }, } = useForm({ defaultValues: { numberOfItems: 1, }, }); - const numberOfItems = watch("numberOfItems"); + const numberOfItems = useWatch({ control, name: "numberOfItems" }); return ( diff --git a/front/src/views/Box/components/TakeItemsFromBoxOverlay.tsx b/front/src/views/Box/components/TakeItemsFromBoxOverlay.tsx index 0776b9e117..3bd89284ec 100644 --- a/front/src/views/Box/components/TakeItemsFromBoxOverlay.tsx +++ b/front/src/views/Box/components/TakeItemsFromBoxOverlay.tsx @@ -15,7 +15,7 @@ import { VStack, } from "@chakra-ui/react"; import SelectField, { IDropdownOption } from "components/Form/SelectField"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { IChangeNumberOfItemsBoxData } from "../BoxView"; import { useAuthorization } from "hooks/useAuthorization"; import { NumberField } from "@boxtribute/shared-components"; @@ -44,7 +44,6 @@ function TakeItemsFromBoxOverlay({ const { handleSubmit, control, - watch, formState: { isSubmitting, errors }, } = useForm({ defaultValues: { @@ -54,8 +53,8 @@ function TakeItemsFromBoxOverlay({ }); const authorize = useAuthorization(); - const selectedLocationId = watch("locationId"); - const numberOfItems = watch("numberOfItems"); + const selectedLocationId = useWatch({ control, name: "locationId" }); + const numberOfItems = useWatch({ control, name: "numberOfItems" }); const onSubmit = (data: ITakeItemsFromBoxData) => { if (data.locationId?.value && onSubmitCreateBoxFromBox) { diff --git a/front/src/views/Transfers/ShipmentView/components/ShipmentContent.tsx b/front/src/views/Transfers/ShipmentView/components/ShipmentContent.tsx index 07842b4aec..7a0510c8be 100644 --- a/front/src/views/Transfers/ShipmentView/components/ShipmentContent.tsx +++ b/front/src/views/Transfers/ShipmentView/components/ShipmentContent.tsx @@ -202,7 +202,7 @@ function ShipmentContent({ }} onClick={ !isExpanded && !isLoadingMutation - ? () => onBulkRemoveBox(item.boxes.map((b) => b?.labelIdentifier!)) + ? () => onBulkRemoveBox(item.boxes.map((b) => b?.labelIdentifier ?? "")) : undefined } /> diff --git a/front/src/views/Transfers/ShipmentsOverview/components/ShipmentExportButton.tsx b/front/src/views/Transfers/ShipmentsOverview/components/ShipmentExportButton.tsx index 5cacb45f58..f7aba89098 100644 --- a/front/src/views/Transfers/ShipmentsOverview/components/ShipmentExportButton.tsx +++ b/front/src/views/Transfers/ShipmentsOverview/components/ShipmentExportButton.tsx @@ -239,6 +239,7 @@ const ShipmentExportButton: React.FC = ({ filteredRow } } } catch (err) { + console.error(err); createToast({ type: "error", message: "An error occurred while exporting shipments. Please try again.",