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
24 changes: 13 additions & 11 deletions front/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "regenerator-runtime/runtime";
import { ReactElement, Suspense, useEffect, useRef, useState } from "react";
import { ReactElement, Suspense, useEffect, useMemo, useRef } from "react";
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences";
import Layout from "components/Layout";
Expand Down Expand Up @@ -99,27 +99,29 @@ function DropappRedirect({ path }: DropappRedirectProps) {
function App() {
const { error, isInitialized } = useLoadAndSetGlobalPreferences();
const location = useLocation();
const [prevLocation, setPrevLocation] = useState<string | undefined>(undefined);
// For BoxesView to reduce number of expensive Boxes queries
// when navigating between boxes and other views.
const hasExecutedInitialFetchOfBoxes = useRef(false);

// store previous location to return to if you are not authorized
useEffect(() => {
const regex = /^\/bases\/\d+\//;
// only store previous location if a base is selected
if (regex.test(location.pathname)) setPrevLocation(location.pathname);
}, [location]);

if (error) {
return <ErrorView error={error} />;
}
// only store previous location if a base is selected
const prevLocation = useMemo(() => {
if (/^\/bases\/\d+\//.test(location.pathname)) {
return location.pathname;
} else {
return;
}
}, [location.pathname]);

// selectedBaseId not set yet
if (!isInitialized) {
return;
}

if (error && isInitialized) {
return <ErrorView error={error} />;
}
Comment on lines 116 to +123
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

Error rendering is currently gated behind isInitialized; if global preferences fail to load (e.g., bases query errors) and selectedBaseId stays "0", the app returns nothing and never displays the error. Handle error before the early return (or render a loading/error state while !isInitialized) so failures don't result in a blank screen.

Copilot uses AI. Check for mistakes.

return (
<Routes>
<Route path="bases">
Expand Down
43 changes: 32 additions & 11 deletions front/src/components/HeaderMenu/BaseSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
RadioGroup,
Stack,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import { useAtomValue } from "jotai";
import { availableBasesAtom, selectedBaseIdAtom } from "stores/globalPreferenceStore";
Expand All @@ -22,20 +22,36 @@ function BaseSwitcher({ isOpen, onClose }: { isOpen: boolean; onClose: () => voi
const { pathname } = useLocation();
const baseId = useAtomValue(selectedBaseIdAtom);
const availableBases = useAtomValue(availableBasesAtom);
const currentOrganisationBases = availableBases.filter((base) => base.id !== baseId);
const firstAvailableBaseId = currentOrganisationBases.find((base) => base)?.id;
const [value, setValue] = useState(firstAvailableBaseId);

// Need to set this as soon as we have this value available to set the default radio selection.
useEffect(() => {
setValue(firstAvailableBaseId);
}, [firstAvailableBaseId, baseId]);
const currentOrganisationBases = useMemo(
() => availableBases.filter((base) => base.id !== baseId),
[availableBases, baseId],
);

const firstAvailableBaseId = useMemo(
() => currentOrganisationBases[0]?.id,
[currentOrganisationBases],
);

const [value, setValue] = useState("");

const switchBase = () => {
const currentPath = pathname.split(`/bases/${urlBaseId}`)[1];

navigate(`/bases/${value}${currentPath}`);
const actValue = value || firstAvailableBaseId;

if (!actValue) {
return;
}

navigate(`/bases/${actValue}${currentPath}`);
onClose();

// Need to reset the default radio selection whenever the available bases change.

const currentOrganisationBases = availableBases.filter((base) => base.id !== actValue);
const newFirstAvailableBaseId = currentOrganisationBases[0]?.id;
setValue(newFirstAvailableBaseId || "");
};
Comment on lines +41 to 55
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

newFirstAvailableBaseId can be undefined (when there are no remaining bases), but value state is initialized as a string (useState("")). Calling setValue(newFirstAvailableBaseId) can therefore break type safety and may lead to navigating with an undefined base id. Use an explicit fallback (e.g. empty string) and/or early-return when actValue/firstAvailableBaseId is missing.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to 55
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

newFirstAvailableBaseId is string | undefined (because of [0]?.id), but value state is a string. Calling setValue(newFirstAvailableBaseId) can set the state to undefined and also risks passing an undefined value into RadioGroup. Use a string fallback (e.g., newFirstAvailableBaseId ?? "") or widen the state type and normalize before rendering/navigating.

Copilot uses AI. Check for mistakes.

return (
Expand All @@ -46,7 +62,7 @@ function BaseSwitcher({ isOpen, onClose }: { isOpen: boolean; onClose: () => voi
<ModalHeader>Switch Base to</ModalHeader>
<ModalCloseButton />
<ModalBody>
<RadioGroup onChange={setValue} value={value}>
<RadioGroup onChange={setValue} value={value || firstAvailableBaseId}>
<Stack ml={"30%"}>
{currentOrganisationBases?.map((base) => (
<Radio key={base.id} value={base.id}>
Expand All @@ -60,7 +76,12 @@ function BaseSwitcher({ isOpen, onClose }: { isOpen: boolean; onClose: () => voi
<Button onClick={onClose} width="100%">
Nevermind
</Button>
<Button colorScheme="blue" width="100%" onClick={switchBase} isDisabled={!value}>
<Button
colorScheme="blue"
width="100%"
onClick={switchBase}
isDisabled={!(value || firstAvailableBaseId)}
>
Switch
</Button>
</ModalFooter>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@apollo/client";
import { useAtomValue } from "jotai";
import { GET_SCANNED_BOXES } from "queries/local-only";
Expand Down Expand Up @@ -167,12 +167,14 @@ function QrReaderMultiBoxContainer() {
}));
}, [baseId, hasShipmentPermission, optionsQueryResult.data]);

// Assign To Shipment is default MultiBoxAction if there are shipments
// Assign To Shipment is default MultiBoxAction if there are shipments (set once on first load)
const defaultShipmentActionForBaseId = useRef("");
useEffect(() => {
if (shipmentOptions.length > 0) {
if (shipmentOptions.length > 0 && defaultShipmentActionForBaseId.current !== baseId) {
defaultShipmentActionForBaseId.current = baseId;
setMultiBoxAction(IMultiBoxAction.assignShipment);
}
}, [shipmentOptions]);
}, [baseId, shipmentOptions.length]);

const notInStockBoxes = useMemo(
() => scannedBoxesQueryResult.data?.scannedBoxes.filter((box) => box.state !== "InStock") ?? [],
Expand Down
2 changes: 1 addition & 1 deletion front/src/components/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { User } from "../../../../graphql/types";
export interface ITimelineEntry {
action: string;
createdOn: Date;
createdBy: User;
createdBy: Partial<User> | null | undefined;
}

export interface IGroupedRecordEntry {
Expand Down
170 changes: 103 additions & 67 deletions front/src/hooks/useLoadAndSetGlobalPreferences.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo } from "react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useAuth0 } from "@auth0/auth0-react";
import { useLazyQuery } from "@apollo/client";
Expand All @@ -17,7 +17,6 @@ export const useLoadAndSetGlobalPreferences = () => {
const { user } = useAuth0();
const authorize = useAuthorization();
const location = useLocation();
const [error, setError] = useState<string>();
const setOrganisation = useSetAtom(organisationAtom);
const [selectedBase, setSelectedBase] = useAtom(selectedBaseAtom);
const [availableBases, setAvailableBases] = useAtom(availableBasesAtom);
Expand All @@ -26,112 +25,149 @@ export const useLoadAndSetGlobalPreferences = () => {
// Boxtribute God user
const isGod: boolean = (user && user[JWT_ROLE]?.includes("boxtribute_god")) || false;

// Set in localStore if current user can Share Public Dashboard Views
localStorage.setItem(
"canShareLink",
authorize({ requiredAbps: ["create_shareable_link"] }).toString(),
);

// validate if base Ids are set in auth0 id token
if (!user || (!isGod && !user[JWT_AVAILABLE_BASES]?.length))
setError("You do not have access to any bases.");
useEffect(() => {
// Set in localStore if current user can Share Public Dashboard Views
localStorage.setItem(
"canShareLink",
authorize({ requiredAbps: ["create_shareable_link"] }).toString(),
);
}, [authorize]);

const [
runOrganisationAndBasesQuery,
{ loading: isOrganisationAndBasesQueryLoading, data: organisationAndBaseData },
{ loading: isOrganisationAndBasesQueryLoading, data: organisationAndBaseData, error, called },
] = useLazyQuery(ORGANISATION_AND_BASES_QUERY);

const localError = useMemo(() => {
if (!user || (!isGod && !user[JWT_AVAILABLE_BASES]?.length)) {
return "You do not have access to any bases.";
} else {
const urlBaseIdInput = location.pathname.match(/\/bases\/(\d+)(\/)?/);
const urlBaseId = urlBaseIdInput?.length && urlBaseIdInput[1];
if (urlBaseId && !isGod && !user[JWT_AVAILABLE_BASES].map(String).includes(urlBaseId)) {
return "The requested base is not available to you.";
}
}

return undefined;
}, [isGod, location.pathname, user]);

// run query only if
// - the access token is in the request header from the apollo client and
// - the base Name is not set
useEffect(() => {
if (user && !selectedBase?.name && !error) {
if (user && !selectedBase?.name && !localError) {
runOrganisationAndBasesQuery();
}
}, [runOrganisationAndBasesQuery, user, selectedBase?.name, error]);
}, [runOrganisationAndBasesQuery, user, selectedBase?.name, localError]);

// setting auth atoms initially from auth0
useEffect(() => {
if (!error && user && (user[JWT_AVAILABLE_BASES] || isGod)) {
// set available bases from auth0 id token only if they are not set yet.
// Otherwise, it would overwrite the names queried from the BE.
if (!availableBases.length && !isGod) {
setAvailableBases(user[JWT_AVAILABLE_BASES].map((id: string) => ({ id })));
}

// extract the current/selected base ID from the URL, default to "0" until a valid base ID is set
const urlBaseIdInput = location.pathname.match(/\/bases\/(\d+)(\/)?/);
const urlBaseId = urlBaseIdInput?.length && urlBaseIdInput[1];

// validate that
// - the selected base ID is part of the available base IDs from Auth0 or
// - that the user is a Boxtribute God
if (urlBaseId) {
if (isGod) {
setSelectedBase({ id: urlBaseId });
} else if (user[JWT_AVAILABLE_BASES].map(String).includes(urlBaseId)) {
if (selectedBaseId !== urlBaseId) {
// only overwrite the selected base ID if the id is different from the existing one.
setSelectedBase({ id: urlBaseId });
if (!isOrganisationAndBasesQueryLoading && organisationAndBaseData !== undefined) {
if (!localError && user && (user[JWT_AVAILABLE_BASES] || isGod)) {
const basesWithOrgData = organisationAndBaseData.bases;
const bases = basesWithOrgData.map((base) => ({
id: base.id,
name: base.name,
}));
if (bases.length > 0) {
if (JSON.stringify(availableBases) !== JSON.stringify(bases)) {
setAvailableBases(bases);
}
// set available bases from auth0 id token only if they are not set yet.
Comment on lines +73 to +77
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

setAvailableBases(bases) runs unconditionally whenever this effect fires (and the dependency list includes location.pathname), so navigating between routes can repeatedly overwrite the atom with a new array reference even when bases haven’t changed, causing avoidable rerenders. Consider guarding the setter (e.g., compare IDs/names) or narrowing dependencies so bases are only written when organisationAndBaseData.bases actually changes.

Copilot uses AI. Check for mistakes.
// Otherwise, it would overwrite the names queried from the BE.
// if (!availableBases.length && !isGod) {
// setAvailableBases(user[JWT_AVAILABLE_BASES].map((id: string) => ({ id })));
// }

// extract the current/selected base ID from the URL, default to "0" until a valid base ID is set
const urlBaseIdInput = location.pathname.match(/\/bases\/(\d+)(\/)?/);
const urlBaseId = urlBaseIdInput?.length && urlBaseIdInput[1];

// validate that
// - the selected base ID is part of the available base IDs from Auth0 or
// - that the user is a Boxtribute God
if (urlBaseId) {
if (isGod) {
// setSelectedBase({ id: urlBaseId });
const matchingBase = basesWithOrgData.find((base) => base.id === urlBaseId);

if (matchingBase) {
// set selected base
setSelectedBase({ id: matchingBase.id, name: matchingBase.name });
// set organisation for selected base
setOrganisation(matchingBase.organisation);
}
} else if (user[JWT_AVAILABLE_BASES].map(String).includes(urlBaseId)) {
// if (selectedBaseId !== urlBaseId) {
// only overwrite the selected base ID if the id is different from the existing one.
// setSelectedBase({ id: urlBaseId });

const matchingBase = basesWithOrgData.find((base) => base.id === urlBaseId);

if (matchingBase) {
// set selected base
setSelectedBase({ id: matchingBase.id, name: matchingBase.name });
// set organisation for selected base
setOrganisation(matchingBase.organisation);
}
// }
}
}
} else {
setError("The requested base is not available to you.");
}
}
}
}, [
availableBases.length,
error,
availableBases,
localError,
isGod,
isOrganisationAndBasesQueryLoading,
location.pathname,
organisationAndBaseData,
setAvailableBases,
setOrganisation,
setSelectedBase,
user,
isGod,
selectedBaseId,
]);

// handle additional base information being returned from the query
useEffect(() => {
if (!isOrganisationAndBasesQueryLoading && organisationAndBaseData !== undefined) {
const finalError = useMemo(() => {
if (organisationAndBaseData) {
const basesWithOrgData = organisationAndBaseData.bases;
const bases = basesWithOrgData.map((base) => ({
const bases = basesWithOrgData?.map((base) => ({
id: base.id,
name: base.name,
}));

if (bases.length > 0) {
setAvailableBases(bases);
if (!bases || bases.length <= 0) {
return "There are no available bases.";
} else if (selectedBase?.id) {
const matchingBase = basesWithOrgData?.find((base) => base.id === selectedBase.id);

if (selectedBase?.id) {
const matchingBase = basesWithOrgData.find((base) => base.id === selectedBase.id);

if (matchingBase) {
// set selected base
setSelectedBase({ id: matchingBase.id, name: matchingBase.name });
// set organisation for selected base
setOrganisation(matchingBase.organisation);
} else {
// this error is set if the requested base is not part of the available bases
setError("The requested base is not available to you.");
}
if (!matchingBase) {
return "The requested base is not available to you.";
}
} else {
// this error is set if the bases query returned an empty array for bases
setError("There are no available bases.");
}

return localError;
} else if (error) {
return "Failed getting information " + error.message;
} else if (!isOrganisationAndBasesQueryLoading && called) {
return "The requested base is not available to you";
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

finalError currently returns "The requested base is not available to you" in the fallback branch when organisationAndBaseData is still undefined and the Apollo query hasn't errored. This makes the hook report an error during the normal loading/initialization phase and can cause the app to show ErrorView prematurely. The fallback should return undefined (or localError) while loading, and only return an error once you have either a computed access error or an actual query error.

Suggested change
return "The requested base is not available to you";
return localError;

Copilot uses AI. Check for mistakes.
} else {
return;
Comment on lines +133 to +157
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

finalError returns "The requested base is not available to you" whenever organisationAndBaseData is undefined and isOrganisationAndBasesQueryLoading is false. With useLazyQuery, that state also occurs before the query has ever been invoked, which can surface a misleading error during initial renders. Consider using the called flag from useLazyQuery (or an explicit "hasStartedQuery" ref/state) and only return this error when the query was actually executed and finished without data.

Copilot uses AI. Check for mistakes.
}
}, [
called,
error,
isOrganisationAndBasesQueryLoading,
localError,
organisationAndBaseData,
selectedBase?.id,
setAvailableBases,
setOrganisation,
setSelectedBase,
]);

const isLoading = !selectedBase?.name || isOrganisationAndBasesQueryLoading;

const isInitialized = selectedBaseId !== "0";
const isInitialized = !isLoading && selectedBaseId !== "0";

return { isLoading, error, isInitialized };
return { isLoading, error: finalError, isInitialized };
};
Loading
Loading