Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -69,7 +69,6 @@ export function MatchProductsForm({
const {
handleSubmit,
control,
watch,
resetField,
formState: { errors, isSubmitting },
} = useForm<MatchProductsFormData>({
Expand All @@ -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<string | undefined>();

// 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<string, IProductWithSizeRangeData[]> = groupBy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,15 +57,14 @@ export function ReceiveLocationForm({
// react-hook-form
const {
control,
watch,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(ReceiveLocationFormDataSchema),
defaultValues,
});

const locationId = watch("locationId");
const locationId = useWatch({ control, name: "locationId" });

useEffect(() => {
if (
Expand Down
38 changes: 19 additions & 19 deletions front/src/components/Walkthrough/desktop/TourOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,8 +33,7 @@ export const PATHS: Record<string, WalkthroughPath> = {
// 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;
Expand Down Expand Up @@ -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;
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without the activePath dependency and without setStepIndex(0), the following happens:

  1. log in as coordinator
  2. open the walkthrough
  3. select first path
  4. forward until step 3
  5. in the top right corner, select path 2 from the dropdown
  6. Observed: path 2 opens but the tooltip is squeezed into the top left corner, not highlighting the actual menu item. Also it shows step 3, not step 1. Expected: the tooltip correctly highlights the Dashboard menu item (step 1 for path 2)


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;
}
Expand All @@ -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;
Expand All @@ -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();
}
}
Expand Down
41 changes: 26 additions & 15 deletions front/src/components/Walkthrough/desktop/WalkthroughContext.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<WalkthroughStep>(() => {
const state = loadState(userId);
return !state.hasSeenWelcome ? "welcome" : "pathSelection";
});

const [completedPaths, setCompletedPaths] = useState<Set<PathId>>(() => {
const state = loadState(userId);
return new Set(state.completedPaths);
});

const [isWalkthroughActive, setIsWalkthroughActive] = useState(false);
const [currentStep, setCurrentStep] = useState<WalkthroughStep>("welcome");
const [activePath, setActivePath] = useState<PathId | null>(null);
const [completedPaths, setCompletedPaths] = useState<Set<PathId>>(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<PathId>) => {
Expand All @@ -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");
Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<MobileWalkthroughStep>("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<MobileWalkthroughStep>(() => {
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 });
Expand Down
Loading
Loading