diff --git a/docs/adr/adr-global-state-management.md b/docs/adr/adr-global-state-management.md new file mode 100644 index 000000000..dcb482c80 --- /dev/null +++ b/docs/adr/adr-global-state-management.md @@ -0,0 +1,167 @@ +# ADR: Use Jotai for Global State Management + +Author: [Felipe](https://github.com/fhenrich33) + +Trello-card: https://trello.com/c/CK6TtXPS/1495-20-fe-replace-globalpreferencescontext + +## Status + +Proposed, Implementing + +## Context or Problem Statement + +We need a global state management solution for our React application. Currently, we are using React Context for this purpose. However, we have encountered some issues with ease of use and rendering performance. We are considering switching to a more efficient state management library and only leaving React Context for mandatory Dependency Injection. + +## Decision Drivers + +1. **Ease of development:** How simple is it to implement and maintain the state management solution? +2. **Rendering performance:** How well does the solution minimize unnecessary re-renders? +3. **Scalability:** How well does the solution handle increasing complexity and state size? +4. **Community and support:** How active and supportive is the community around the solution? + +## Considered Options + +1. Continue using React Context +2. Switch to Jotai +3. Switch to Zustand +4. Switch to Legend State + +## Consequences + +### Advantages of Jotai + +1. **Ease of development:** Jotai provides a simpler and more intuitive API for managing state compared to React Context. It allows for more granular control over state updates, making it easier to manage complex state logic. +2. **Rendering performance:** Jotai minimizes unnecessary re-renders by only updating components that depend on the specific piece of state that has changed. This leads to better performance compared to React Context, which can cause entire component trees to re-render when the context value changes. +3. **Scalability:** Jotai's atom-based approach allows for better scalability as the state grows in size and complexity. Each atom represents a single piece of state, making it easier to manage and reason about the state. +4. **Community and support:** Jotai has an active and growing community, with good documentation and support. It is also backed by the same team that maintains Zustand, another popular state management library. + +#### Jotai Example + +```javascript +import { atom, useAtom } from "jotai"; + +const countAtom = atom(0); + +function Counter() { + const [count, setCount] = useAtom(countAtom); + return ( +
+ + {count} + +
+ ); +} +``` + +### Drawbacks of Jotai + +1. **Learning curve:** While Jotai is simpler to use than React Context, it still requires developers to learn a new API and mental model for managing state. +2. **Ecosystem:** React Context is a built-in feature of React, while Jotai is an external library. This means that Jotai may not have the same level of ecosystem integration and support as React Context. + +### Advantages of Zustand + +1. **Ease of development:** Zustand provides a simple and flexible API for managing state, with minimal boilerplate. +2. **Rendering performance:** Zustand minimizes unnecessary re-renders by using a subscription-based model, ensuring that only the components that depend on the changed state are updated. +3. **Scalability:** Zustand's flexible API allows for easy management of complex state logic and large state trees. +4. **Community and support:** Zustand has a growing community and is maintained by the same team as Jotai, ensuring good support and documentation. + +#### Zustand Example + +```javascript +import create from "zustand"; + +const useStore = create((set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), + decrement: () => set((state) => ({ count: state.count - 1 })), +})); + +function Counter() { + const { count, increment, decrement } = useStore(); + return ( +
+ + {count} + +
+ ); +} +``` + +### Drawbacks of Zustand + +1. **Learning curve:** Zustand requires developers to learn a new API and mental model for managing state. +2. **Ecosystem:** Zustand, like Jotai, is an external library and may not have the same level of ecosystem integration as React Context. + +### Advantages of Legend State + +1. **Ease of development:** Legend State provides a declarative API for managing state, making it easy to understand and use. +2. **Rendering performance:** Legend State optimizes rendering performance by using a fine-grained reactivity system, ensuring that only the necessary components are updated. +3. **Scalability:** Legend State's declarative approach allows for easy management of complex state logic and large state trees. +4. **Community and support:** Legend State has a dedicated community and good documentation, providing ample support for developers. + +#### Legend State Example + +```javascript +import { observable } from "@legendapp/state"; +import { observer } from "@legendapp/state/react"; + +const count = observable(0); + +const Counter = observer(() => ( +
+ + {count.get()} + +
+)); +``` + +### Drawbacks of Legend State + +1. **Learning curve:** Legend State requires developers to learn a new API and mental model for managing state. +2. **Ecosystem:** Legend State is an external library and may not have the same level of ecosystem integration as React Context. + +## Comparison with React Context + +### Ease of development + +- **React Context:** Requires creating context providers and consumers, which can lead to boilerplate code and complexity in managing state updates. The least ergonomic API of them all, ironically. +- **Jotai:** Provides a simpler API with atoms and hooks, reducing boilerplate and making it easier to manage state updates. Think of it as `useState` but global and can derive state from multiple atoms. +- **Zustand:** Offers a simple and flexible API with minimal boilerplate, making it easy to manage state updates. Similar to Mobx and slightly so with Redux. +- **Legend State:** Provides a declarative API, making it easy to understand and use for basic cases. Not as great when using the API for maximum performance (wrapping components in `observer`, which looks like old HoC code). + +### Rendering performance + +See this [benchmark](https://legendapp.com/open-source/state/v2/intro/fast/) from the Legend State docs for reference. + +- **React Context:** Can cause entire component trees to re-render when the context value changes, leading to potential performance issues and unnecessary re-renders. +- **Jotai:** Minimizes unnecessary re-renders by only updating components that depend on the specific piece of state that has changed. Second fastest while retaining very familiar API to React devs. +- **Zustand:** Uses a subscription-based model to minimize unnecessary re-renders. Faster than Context but slower than the others in this comparison. +- **Legend State:** Optimizes rendering performance with a fine-grained reactivity system. The fastest of all options, with the caveat of the optimal code not looking like standard React. + +### Scalability + +- **React Context:** Can become difficult to manage as the state grows in size and complexity, leading to potential performance and maintainability issues. +- **Jotai:** Atom-based approach allows for better scalability, making it easier to manage and reason about the state. +- **Zustand:** Flexible API allows for easy management of complex state logic and large state trees. Easier Redux-like state management. +- **Legend State:** Declarative approach allows for easy management of complex state logic and large state trees. There's the caveat of the non-standard looking API for the optimal performant code. + +### Community and support + +- **React Context:** Built-in feature of React with a large community and ecosystem support. +- **Jotai:** Excellent community with good documentation and support, second only to Zustand (maintained by the same team as Jotai), but may not have the same level of ecosystem integration as React Context (since it's baked in React). +- **Zustand:** Excellent community with good support and documentation, maintained by the same team as Jotai, but again, may not have the same level of ecosystem integration as React Context +- **Legend State:** Fairly dedicated community with good documentation and support, but less so than the other options. Worse integration than the others as Legend State removes itself from React conventions to achieve top performance. + +## Decision + +We are considering switching to Jotai for global state management in our React application. Jotai provides a simpler and more intuitive API, better rendering performance, and improved scalability compared to React Context. While Zustand and Legend State also offer significant advantages, Jotai's balance of ease of development and performance makes it the best choice for our needs. + +## References + +- [React Context Documentation](https://reactjs.org/docs/context.html) +- [Jotai Documentation](https://jotai.org/docs/introduction) +- [Zustand Documentation](https://zustand.surge.sh/) +- [Legend State Documentation](https://legendapp.com/open-source/state/v3/) diff --git a/docs/adr/adr_frontend_global_state.md b/docs/adr/adr_frontend_global_state.md index b53a8208b..627eea76d 100644 --- a/docs/adr/adr_frontend_global_state.md +++ b/docs/adr/adr_frontend_global_state.md @@ -8,6 +8,8 @@ Author: HaGuesto Early Discussion and research phase +Update: [Use Jotai for Global State Management](./adr-global-state-management.md) + ## Context or Problem Statement There are a few mutable global variables/shared states in the frontend that we want to access in all of the DOM. The basic example is something like `Dark Mode`. In our case, information about the bases and organisations a user has access to is best saved globally and not queried each time a new. @@ -27,6 +29,7 @@ Since we are using Apollo for graphQL queries we can also use the Apollo Reactiv ## Considered Options ### React Context (with useReducer) + Usually, one writes a Reducer in addition to React Context when you handle global states. In some simple cases the `useState` hook should be enough, but is not recommended. The reasons are: 1. Predictable state changes: Reducers enforce a predictable way of changing state, which can help prevent bugs caused by unexpected state changes. Since reducers always produce a new state based on the previous state and an action, it's easier to reason about how the state changes in response to different actions. @@ -47,7 +50,6 @@ Here are some general pros and cons to consider: - can require more boilerplate code for setting up and managing the context and reducer functions - can be less performant than other global state management approaches like Redux, especially if the state is deeply nested or frequently updated - ### [Apollo local state](https://www.apollographql.com/docs/react/local-state/local-state-management) Similar as for React Context, one should distinguish between the Reactive Variables of Apollo and working with local-only cache fields. Only the later is really recommended for global state changes. Similar to a Reducer you also have to define the read and write queries for local-only cache fields. Reactive Variables are very similar to the concept of the `useState` hook. Therefore, to the same reasons as before we only consider local-only fields here. @@ -70,9 +72,10 @@ Here are some general pros and cons to consider: React Context and Apollo can both and should both be used for global state management in React depending on the specific requirements of the feature. ### Comment -In general, a mix out of both considered options is most likely needed to handle mutable shared states. In some cases the Apollo has more advantages (especially for remote states), sometimes the React Context is better to use. + +In general, a mix out of both considered options is most likely needed to handle mutable shared states. In some cases the Apollo has more advantages (especially for remote states), sometimes the React Context is better to use. We should try out Apollo when the next mutable shared state comes around like for the QrReader when scanning multiple Boxes. -There is no need to refactor the Global Preference Provider at the moment. Removing the React Context of the Global Preference Provider and creatingthe same structure in Apollo for it, is just unnecassary work. The Global Preference Provider works and the code is clean. +There is no need to refactor the Global Preference Provider at the moment. Removing the React Context of the Global Preference Provider and creatingthe same structure in Apollo for it, is just unnecassary work. The Global Preference Provider works and the code is clean. ## Reference diff --git a/front/package.json b/front/package.json index 3b5d523d9..ccac97b8e 100644 --- a/front/package.json +++ b/front/package.json @@ -15,6 +15,7 @@ "@zxing/browser": "^0.1.5", "@zxing/library": "^0.21.3", "chakra-react-select": "4.9.2", + "jotai": "^2.10.3", "react-big-calendar": "^1.17.0", "react-icons": "^5.4.0", "react-table": "^7.8.0", diff --git a/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.tsx b/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.tsx index 45a0b8d0f..b156d0714 100644 --- a/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.tsx +++ b/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useReactiveVar } from "@apollo/client"; +import { useAtomValue } from "jotai"; import { boxReconciliationOverlayVar } from "queries/cache"; import { SHIPMENT_BY_ID_WITH_PRODUCTS_AND_LOCATIONS_QUERY } from "queries/queries"; import { UPDATE_SHIPMENT_WHEN_RECEIVING } from "queries/mutations"; @@ -14,7 +15,7 @@ import { ILocationData, IProductWithSizeRangeData, } from "./components/BoxReconciliationView"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; export interface IBoxReconciliationOverlayData { shipmentDetail: ShipmentDetail; @@ -31,7 +32,7 @@ export function BoxReconciliationOverlay({ }) { const { createToast } = useNotification(); const { triggerError } = useErrorHandling(); - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const boxReconciliationOverlayState = useReactiveVar(boxReconciliationOverlayVar); const [boxUndeliveredAYSState, setBoxUndeliveredAYSState] = useState(""); const navigate = useNavigate(); diff --git a/front/src/components/BreadcrumbNavigation.tsx b/front/src/components/BreadcrumbNavigation.tsx index ecd24d50c..814620d86 100644 --- a/front/src/components/BreadcrumbNavigation.tsx +++ b/front/src/components/BreadcrumbNavigation.tsx @@ -1,10 +1,10 @@ -import { useContext } from "react"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, Button } from "@chakra-ui/react"; import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons"; import { Link } from "react-router-dom"; import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; import { BreadcrumbNavigationSkeleton } from "./Skeletons"; +import { useAtomValue } from "jotai"; +import { organisationAtom, selectedBaseAtom } from "stores/globalPreferenceStore"; interface IBreadcrumbItemData { label: string; @@ -35,9 +35,10 @@ export function MobileBreadcrumbButton({ label, linkPath }: IBreadcrumbItemData) } export function BreadcrumbNavigation({ items }: IBreadcrumbNavigationProps) { - const { globalPreferences } = useContext(GlobalPreferencesContext); - const orgName = globalPreferences.organisation?.name; - const baseName = globalPreferences.selectedBase?.name; + const organisation = useAtomValue(organisationAtom); + const selectedBase = useAtomValue(selectedBaseAtom); + const orgName = organisation?.name; + const baseName = selectedBase?.name; const { isLoading: isGlobalStateLoading } = useLoadAndSetGlobalPreferences(); if (isGlobalStateLoading) return ; diff --git a/front/src/components/HeaderMenu/BaseSwitcher.tsx b/front/src/components/HeaderMenu/BaseSwitcher.tsx index 811e978e0..ab65c43dc 100644 --- a/front/src/components/HeaderMenu/BaseSwitcher.tsx +++ b/front/src/components/HeaderMenu/BaseSwitcher.tsx @@ -11,27 +11,24 @@ import { RadioGroup, Stack, } from "@chakra-ui/react"; -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; - -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { useAtomValue } from "jotai"; +import { availableBasesAtom, selectedBaseIdAtom } from "stores/globalPreferenceStore"; function BaseSwitcher({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { const navigate = useNavigate(); const { pathname } = useLocation(); - const { baseId: currentBaseId } = useBaseIdParam(); - const { globalPreferences } = useContext(GlobalPreferencesContext); - const currentOrganisationBases = globalPreferences.availableBases?.filter( - (base) => base.id !== currentBaseId, - ); + 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, currentBaseId]); + }, [firstAvailableBaseId, baseId]); const switchBase = () => { const currentPath = pathname.split("/bases/")[1].substring(1); diff --git a/front/src/components/HeaderMenu/HeaderMenuContainer.tsx b/front/src/components/HeaderMenu/HeaderMenuContainer.tsx index 98cb9d8b0..124ccc870 100644 --- a/front/src/components/HeaderMenu/HeaderMenuContainer.tsx +++ b/front/src/components/HeaderMenu/HeaderMenuContainer.tsx @@ -1,14 +1,15 @@ import { useMemo } from "react"; +import { useAtomValue } from "jotai"; +import { useReactiveVar } from "@apollo/client"; import QrReaderOverlay from "components/QrReaderOverlay/QrReaderOverlay"; import { qrReaderOverlayVar } from "queries/cache"; -import { useReactiveVar } from "@apollo/client"; import { useAuthorization } from "hooks/useAuthorization"; import HeaderMenu, { MenuItemsGroupData } from "./HeaderMenu"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; function HeaderMenuContainer() { const authorize = useAuthorization(); - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const qrReaderOverlayState = useReactiveVar(qrReaderOverlayVar); // TODO: do this at route definition diff --git a/front/src/components/HeaderMenu/MenuDesktop.tsx b/front/src/components/HeaderMenu/MenuDesktop.tsx index cd35741dd..3d53c2e58 100644 --- a/front/src/components/HeaderMenu/MenuDesktop.tsx +++ b/front/src/components/HeaderMenu/MenuDesktop.tsx @@ -11,24 +11,27 @@ import { NavLink } from "react-router-dom"; import { ACCOUNT_SETTINGS_URL } from "./consts"; import { useHandleLogout } from "hooks/hooks"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; import BoxtributeLogo from "./BoxtributeLogo"; import { IHeaderMenuProps } from "./HeaderMenu"; import MenuIcon, { Icon } from "./MenuIcons"; import { expandedMenuIndex } from "./expandedMenuIndex"; -import { useContext } from "react"; import BaseSwitcher from "./BaseSwitcher"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { useAtomValue } from "jotai"; +import { + availableBasesAtom, + selectedBaseAtom, + selectedBaseIdAtom, +} from "stores/globalPreferenceStore"; function MenuDesktop({ menuItemsGroups }: IHeaderMenuProps) { const { isOpen, onOpen, onClose } = useDisclosure(); const { handleLogout } = useHandleLogout(); - const { baseId: currentBaseId } = useBaseIdParam(); - const { globalPreferences } = useContext(GlobalPreferencesContext); - const baseName = globalPreferences.selectedBase?.name; + const baseId = useAtomValue(selectedBaseIdAtom); + const selectedBase = useAtomValue(selectedBaseAtom); + const availableBases = useAtomValue(availableBasesAtom); + const baseName = selectedBase?.name; const currentOrganisationHasMoreThanOneBaseAvailable = - (globalPreferences.availableBases?.filter((base) => base.id !== currentBaseId).length || 0) >= - 1; + (availableBases?.filter((base) => base.id !== baseId).length || 0) >= 1; const [allowMultipleAccordionsOpen] = useMediaQuery("(min-height: 1080px)"); return ( diff --git a/front/src/components/HeaderMenu/MenuMobile.tsx b/front/src/components/HeaderMenu/MenuMobile.tsx index e0322281e..966357a96 100644 --- a/front/src/components/HeaderMenu/MenuMobile.tsx +++ b/front/src/components/HeaderMenu/MenuMobile.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useContext } from "react"; +import { ReactNode } from "react"; import { Accordion, AccordionButton, @@ -18,15 +18,19 @@ import { import { HamburgerIcon } from "@chakra-ui/icons"; import { useHandleLogout } from "hooks/hooks"; import { NavLink } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { ACCOUNT_SETTINGS_URL } from "./consts"; import { IHeaderMenuProps } from "./HeaderMenu"; import BoxtributeLogo from "./BoxtributeLogo"; import MenuIcon, { Icon } from "./MenuIcons"; import { expandedMenuIndex } from "./expandedMenuIndex"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; import BaseSwitcher from "./BaseSwitcher"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { + selectedBaseAtom, + availableBasesAtom, + selectedBaseIdAtom, +} from "stores/globalPreferenceStore"; function SubItemBox({ children, py = 1 }: { children: ReactNode | ReactNode[]; py?: number }) { return ( @@ -48,12 +52,12 @@ function SubItemBox({ children, py = 1 }: { children: ReactNode | ReactNode[]; p function MenuMobile({ onClickScanQrCode, menuItemsGroups }: IHeaderMenuProps) { const { isOpen, onOpen, onClose } = useDisclosure(); const { handleLogout } = useHandleLogout(); - const { baseId: currentBaseId } = useBaseIdParam(); - const { globalPreferences } = useContext(GlobalPreferencesContext); - const baseName = globalPreferences.selectedBase?.name; + const baseId = useAtomValue(selectedBaseIdAtom); + const selectedBase = useAtomValue(selectedBaseAtom); + const availableBases = useAtomValue(availableBasesAtom); + const baseName = selectedBase?.name; const currentOrganisationHasMoreThanOneBaseAvailable = - (globalPreferences.availableBases?.filter((base) => base.id !== currentBaseId).length || 0) >= - 1; + (availableBases?.filter((base) => base.id !== baseId).length || 0) >= 1; return ( diff --git a/front/src/components/QrReader/QrReaderContainer.tsx b/front/src/components/QrReader/QrReaderContainer.tsx index 2472d75d8..a801894d6 100644 --- a/front/src/components/QrReader/QrReaderContainer.tsx +++ b/front/src/components/QrReader/QrReaderContainer.tsx @@ -1,5 +1,6 @@ import { useCallback, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { useErrorHandling } from "hooks/useErrorHandling"; import { ILabelIdentifierResolvedValue, @@ -12,9 +13,9 @@ import { useReactiveVar } from "@apollo/client"; import { qrReaderOverlayVar } from "queries/cache"; import { AlertWithoutAction } from "components/Alerts"; import QrReader from "./components/QrReader"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; import { QrReaderSkeleton } from "components/Skeletons"; import { Alert, CloseButton, useDisclosure } from "@chakra-ui/react"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; interface IQrReaderContainerProps { onSuccess: () => void; @@ -32,7 +33,7 @@ const IOS_PSA_TEXT = ( ); function QrReaderContainer({ onSuccess }: IQrReaderContainerProps) { - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const navigate = useNavigate(); const { triggerError } = useErrorHandling(); const { resolveQrCode } = useQrResolver(); diff --git a/front/src/components/QrReader/components/QrReaderMultiBoxContainer.tsx b/front/src/components/QrReader/components/QrReaderMultiBoxContainer.tsx index 26404a856..85f0f7b40 100644 --- a/front/src/components/QrReader/components/QrReaderMultiBoxContainer.tsx +++ b/front/src/components/QrReader/components/QrReaderMultiBoxContainer.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "@apollo/client"; +import { useAtomValue } from "jotai"; import { GET_SCANNED_BOXES } from "queries/local-only"; import { MULTI_BOX_ACTION_OPTIONS_FOR_LOCATIONS_TAGS_AND_SHIPMENTS_QUERY } from "queries/queries"; import { IDropdownOption } from "components/Form/SelectField"; @@ -12,7 +13,6 @@ import { useMoveBoxes } from "hooks/useMoveBoxes"; import { useAssignTags } from "hooks/useAssignTags"; import { useAssignBoxesToShipment } from "hooks/useAssignBoxesToShipment"; import { locationToDropdownOptionTransformer } from "utils/transformers"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; import QrReaderMultiBox, { IMultiBoxAction } from "./QrReaderMultiBox"; import { FailedBoxesFromAssignTagsAlert, @@ -20,9 +20,10 @@ import { FailedBoxesFromMoveBoxesAlert, NotInStockAlertText, } from "./AlertTexts"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; function QrReaderMultiBoxContainer() { - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); // selected radio button const [multiBoxAction, setMultiBoxAction] = useState(IMultiBoxAction.moveBox); diff --git a/front/src/hooks/hooks.ts b/front/src/hooks/hooks.ts index 9835b940c..9ec275fd6 100644 --- a/front/src/hooks/hooks.ts +++ b/front/src/hooks/hooks.ts @@ -5,7 +5,8 @@ import { UseToastOptions, ToastPosition } from "@chakra-ui/react"; import { tableConfigsVar } from "queries/cache"; import { useReactiveVar } from "@apollo/client"; import { Filters, SortingRule } from "react-table"; -import { useBaseIdParam } from "./useBaseIdParam"; +import { useAtomValue } from "jotai"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; export interface INotificationProps extends UseToastOptions { title?: string; @@ -31,7 +32,7 @@ export const useHandleLogout = () => { }; export const useGetUrlForResourceHelpers = () => { - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); if (baseId == null) { throw new Error("Could not extract baseId from URL"); } @@ -66,7 +67,7 @@ export const useToggle = (initialValue = false) => { // TODO: Probably need to refactor to remove this, seems unnecessary. export const useGlobalSiteState = () => { - const { baseId: currentBaseId } = useBaseIdParam(); + const currentBaseId = useAtomValue(selectedBaseIdAtom); const navigate = useNavigate(); return { diff --git a/front/src/hooks/useBaseIdParam.ts b/front/src/hooks/useBaseIdParam.ts deleted file mode 100644 index 1adb051be..000000000 --- a/front/src/hooks/useBaseIdParam.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Infers intended base ID from URL. - */ -export function useBaseIdParam() { - - const baseIdInput = location.pathname.match(/\/bases\/(\d+)(\/)?/); - - const baseId = baseIdInput?.length && baseIdInput[1] || "0"; - - return { baseId } -} diff --git a/front/src/hooks/useLoadAndSetGlobalPreferences.ts b/front/src/hooks/useLoadAndSetGlobalPreferences.ts index 2adc35989..cef741c75 100644 --- a/front/src/hooks/useLoadAndSetGlobalPreferences.ts +++ b/front/src/hooks/useLoadAndSetGlobalPreferences.ts @@ -1,56 +1,49 @@ -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; import { useAuth0 } from "@auth0/auth0-react"; import { useLazyQuery } from "@apollo/client"; import { useLocation, useNavigate } from "react-router-dom"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; import { ORGANISATION_AND_BASES_QUERY } from "queries/queries"; -import { useBaseIdParam } from "./useBaseIdParam"; +import { availableBasesAtom, organisationAtom, selectedBaseAtom } from "stores/globalPreferenceStore"; export const useLoadAndSetGlobalPreferences = () => { const { user } = useAuth0(); const navigate = useNavigate(); const location = useLocation(); - const { globalPreferences, dispatch } = useContext(GlobalPreferencesContext); const [error, setError] = useState(); + const setOrganisation = useSetAtom(organisationAtom); + const [availableBases, setAvailableBases] = useAtom(availableBasesAtom); + const [selectedBase, setSelectedBase] = useAtom(selectedBaseAtom); - const { baseId } = useBaseIdParam(); + // extract the current/selected base ID from the URL, default to "0" until a valid base ID is set + const baseIdInput = location.pathname.match(/\/bases\/(\d+)(\/)?/); + const baseId = baseIdInput?.length && baseIdInput[1] || "0"; const [runOrganisationAndBasesQuery, { loading: isOrganisationAndBasesQueryLoading, data: organisationAndBaseData }] = useLazyQuery(ORGANISATION_AND_BASES_QUERY); useEffect(() => { // run query only if the access token is in the request header from the apollo client and the base is not set - if (user && !globalPreferences.selectedBase?.id) { - runOrganisationAndBasesQuery(); - } + if (user && !selectedBase?.id) runOrganisationAndBasesQuery(); }, [runOrganisationAndBasesQuery, - user, globalPreferences.selectedBase?.id]); + user, selectedBase?.id]); // set available bases useEffect(() => { - if (!isOrganisationAndBasesQueryLoading && organisationAndBaseData != null) { + if (!isOrganisationAndBasesQueryLoading && organisationAndBaseData !== undefined) { const { bases } = organisationAndBaseData; if (bases.length > 0) { - dispatch({ - type: "setAvailableBases", - payload: bases, - }); - + setAvailableBases(bases); // validate if requested base is in the array of available bases if (baseId !== "0") { const matchingBase = bases.find((base) => base.id === baseId); + if (matchingBase) { // set selected base - dispatch({ - type: "setSelectedBase", - payload: matchingBase, - }); + setSelectedBase(matchingBase); // set organisation for selected base - dispatch({ - type: "setOrganisation", - payload: matchingBase.organisation, - }); + 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."); @@ -61,9 +54,9 @@ export const useLoadAndSetGlobalPreferences = () => { setError("There are no available bases."); } } - }, [organisationAndBaseData, isOrganisationAndBasesQueryLoading, dispatch, location.pathname, navigate, baseId, globalPreferences?.selectedBase?.id]); + }, [organisationAndBaseData, isOrganisationAndBasesQueryLoading, location.pathname, navigate, selectedBase?.id, setSelectedBase, setOrganisation, setAvailableBases, baseId]); - const isLoading = !globalPreferences.availableBases || !globalPreferences.selectedBase?.id; + const isLoading = !availableBases.length || !selectedBase?.id; - return { isLoading, error }; + return { isLoading, error, urlBaseId: baseId }; }; diff --git a/front/src/index.tsx b/front/src/index.tsx index 26981f552..2fe4bc4b8 100644 --- a/front/src/index.tsx +++ b/front/src/index.tsx @@ -10,7 +10,6 @@ import { ChakraProvider, CSSReset } from "@chakra-ui/react"; import { withAuthenticationRequired } from "@auth0/auth0-react"; import Auth0ProviderWithHistory from "providers/Auth0ProviderWithHistory"; import ApolloAuth0Provider from "providers/ApolloAuth0Provider"; -import { GlobalPreferencesProvider } from "providers/GlobalPreferencesProvider"; import * as Sentry from "@sentry/react"; import App from "./App"; import { theme } from "./utils/theme"; @@ -19,9 +18,7 @@ import React from "react"; const ProtectedApp = withAuthenticationRequired(() => ( - - - + )); diff --git a/front/src/providers/GlobalPreferencesProvider.tsx b/front/src/providers/GlobalPreferencesProvider.tsx deleted file mode 100644 index af875fa0a..000000000 --- a/front/src/providers/GlobalPreferencesProvider.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Context, createContext, useMemo, useReducer } from "react"; - -export interface IIdAndNameTuple { - id: string; - name: string; -} - -export interface IGlobalPreferences { - selectedBase?: IIdAndNameTuple; - availableBases?: IIdAndNameTuple[]; - organisation?: IIdAndNameTuple; -} - -export interface ISetAvailableBasesAction { - type: "setAvailableBases"; - payload: IIdAndNameTuple[]; -} - -export interface ISetSelectedBaseAction { - type: "setSelectedBase"; - payload: IIdAndNameTuple; -} - -export interface ISetOrganisationAction { - type: "setOrganisation"; - payload: IIdAndNameTuple; -} - -export type ISetGlobalPreferencesAction = - | ISetAvailableBasesAction - | ISetSelectedBaseAction - | ISetOrganisationAction; - -export interface IGlobalPreferencesContext { - globalPreferences: IGlobalPreferences; - dispatch: React.Dispatch; -} - -const GlobalPreferencesContext: Context = createContext( - {} as IGlobalPreferencesContext, -); - -export const globalPreferencesReducer = ( - state: IGlobalPreferences, - action: ISetGlobalPreferencesAction, -) => { - switch (action.type) { - case "setAvailableBases": - return { ...state, availableBases: action.payload }; - case "setSelectedBase": - return { ...state, selectedBase: action.payload }; - case "setOrganisation": - return { ...state, organisation: action.payload }; - default: - return state; - } -}; - -function GlobalPreferencesProvider({ children }: { children: React.ReactNode }) { - const [globalPreferences, dispatch] = useReducer(globalPreferencesReducer, {}); - - const memoedGlobalPreferences = useMemo( - () => ({ globalPreferences, dispatch }), - [globalPreferences], - ); - return ( - - {children} - - ); -} - -export { GlobalPreferencesContext, GlobalPreferencesProvider }; diff --git a/front/src/stores/globalPreferenceStore.ts b/front/src/stores/globalPreferenceStore.ts new file mode 100644 index 000000000..759581d12 --- /dev/null +++ b/front/src/stores/globalPreferenceStore.ts @@ -0,0 +1,12 @@ +import { atom } from "jotai"; + +type IdNameTuple = { + id: string; + name: string; +}; + +export const availableBasesAtom = atom([]); +export const organisationAtom = atom(); +export const selectedBaseAtom = atom(); + +export const selectedBaseIdAtom = atom((get) => get(selectedBaseAtom)?.id || "0"); diff --git a/front/src/tests/test-utils.tsx b/front/src/tests/test-utils.tsx index a6cd2ec74..0403f5813 100644 --- a/front/src/tests/test-utils.tsx +++ b/front/src/tests/test-utils.tsx @@ -1,7 +1,6 @@ /* eslint-disable import/export */ // TODO: Investigate possible render function overload. -import { vi } from "vitest"; import React, { ReactNode } from "react"; import { render as rtlRender } from "@testing-library/react"; import { MockedProvider, MockedResponse, MockLink } from "@apollo/client/testing"; @@ -18,11 +17,6 @@ import { ApolloProvider, DefaultOptions, } from "@apollo/client"; -import { - GlobalPreferencesContext, - IGlobalPreferencesContext, -} from "providers/GlobalPreferencesProvider"; -import { organisation1 } from "mocks/organisations"; import { base1 } from "mocks/bases"; import { FakeGraphQLError, FakeGraphQLNetworkError, mockMatchMediaQuery } from "mocks/functions"; @@ -71,7 +65,7 @@ function render( initialUrl: string; additionalRoute?: string; addTypename?: boolean; - globalPreferences?: IGlobalPreferencesContext; + globalPreferences?: any; mediaQueryReturnValue?: boolean; }, ) { @@ -104,14 +98,14 @@ function render( }); const link = ApolloLink.from([errorLoggingLink, mockLink]); - const globalPreferencesMock: IGlobalPreferencesContext = { - dispatch: vi.fn(), - globalPreferences: { - selectedBase: { id: base1.id, name: base1.name }, - organisation: { id: organisation1.id, name: organisation1.name }, - availableBases: organisation1.bases, - }, - }; + // const _globalPreferencesMock: any = { + // dispatch: vi.fn(), + // globalPreferences: { + // selectedBase: { id: base1.id, name: base1.name }, + // organisation: { id: organisation1.id, name: organisation1.name }, + // availableBases: organisation1.bases, + // }, + // }; mockMatchMediaQuery(mediaQueryReturnValue); @@ -125,24 +119,22 @@ function render( const Wrapper: React.FC = ({ children }: any) => ( - - - - - {additionalRoute !== undefined && ( - {additionalRoute}} /> - )} - - - - - + + + + {additionalRoute !== undefined && ( + {additionalRoute}} /> + )} + + + + ); return rtlRender(ui, { diff --git a/front/src/views/BaseDashboard/BaseDashboardView.tsx b/front/src/views/BaseDashboard/BaseDashboardView.tsx index 9fd198f33..47acb2e90 100644 --- a/front/src/views/BaseDashboard/BaseDashboardView.tsx +++ b/front/src/views/BaseDashboard/BaseDashboardView.tsx @@ -1,7 +1,8 @@ import { useQuery } from "@apollo/client"; import { graphql } from "../../../../graphql/graphql"; import { Center, Heading, Text } from "@chakra-ui/react"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { useAtomValue } from "jotai"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; export const BASE_DATA = graphql(` query BaseData($baseId: ID!) { @@ -15,7 +16,7 @@ export const BASE_DATA = graphql(` `); function BaseDashboardView() { - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const { loading, error, data } = useQuery(BASE_DATA, { variables: { diff --git a/front/src/views/Box/BoxView.tsx b/front/src/views/Box/BoxView.tsx index c71f1698e..2b6437689 100644 --- a/front/src/views/Box/BoxView.tsx +++ b/front/src/views/Box/BoxView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, NetworkStatus } from "@apollo/client"; import { graphql } from "gql.tada"; import { @@ -10,7 +10,6 @@ import { useDisclosure, VStack, } from "@chakra-ui/react"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; import { useNavigate, useParams } from "react-router-dom"; import { ASSIGN_BOX_TO_DISTRIBUTION_MUTATION, @@ -44,7 +43,8 @@ import { formatDateKey, prepareBoxHistoryEntryText } from "utils/helpers"; import BoxDetails from "./components/BoxDetails"; import TakeItemsFromBoxOverlay from "./components/TakeItemsFromBoxOverlay"; import AddItemsToBoxOverlay from "./components/AddItemsToBoxOverlay"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { useAtomValue } from "jotai"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; import { BoxState } from "queries/types"; // Queries and Mutations @@ -141,8 +141,7 @@ function BTBox() { const { triggerError } = useErrorHandling(); const { createToast } = useNotification(); const labelIdentifier = useParams<{ labelIdentifier: string }>().labelIdentifier!; - const { globalPreferences } = useContext(GlobalPreferencesContext); - const { baseId: currentBaseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const [currentBoxState, setCurrentState] = useState(); const { isOpen: isHistoryOpen, onOpen: onHistoryOpen, onClose: onHistoryClose } = useDisclosure(); const { @@ -227,9 +226,9 @@ function BTBox() { shipmentId, }); } else if (shipmentId && boxData?.state === "InTransit") { - navigate(`/bases/${currentBaseId}/transfers/shipments/${shipmentId}`); + navigate(`/bases/${baseId}/transfers/shipments/${shipmentId}`); } - }, [boxData, globalPreferences, navigate, currentBaseId]); + }, [boxData, navigate, baseId]); const loading = allData.networkStatus !== NetworkStatus.ready || @@ -555,15 +554,13 @@ function BTBox() { const shipmentOptions: IDropdownOption[] = useMemo( () => shipmentsQueryResult - ?.filter( - (shipment) => shipment.state === "Preparing" && shipment.sourceBase.id === currentBaseId, - ) + ?.filter((shipment) => shipment.state === "Preparing" && shipment.sourceBase.id === baseId) ?.map((shipment) => ({ label: `${shipment.targetBase.name} - ${shipment.targetBase.organisation.name}`, subTitle: shipment.labelIdentifier, value: shipment.id, })) ?? [], - [currentBaseId, shipmentsQueryResult], + [baseId, shipmentsQueryResult], ); if (error) { diff --git a/front/src/views/BoxCreate/BoxCreateView.tsx b/front/src/views/BoxCreate/BoxCreateView.tsx index 9da03eecb..63c538daf 100644 --- a/front/src/views/BoxCreate/BoxCreateView.tsx +++ b/front/src/views/BoxCreate/BoxCreateView.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery } from "@apollo/client"; import { graphql } from "../../../../graphql/graphql"; import { Center } from "@chakra-ui/react"; @@ -9,9 +9,9 @@ import { useNavigate, useParams } from "react-router-dom"; import { TAG_OPTIONS_FRAGMENT, PRODUCT_FIELDS_FRAGMENT } from "queries/fragments"; import { CHECK_IF_QR_EXISTS_IN_DB } from "queries/queries"; import BoxCreate, { ICreateBoxFormData } from "./components/BoxCreate"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; import { AlertWithoutAction } from "components/Alerts"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; +import { selectedBaseAtom, selectedBaseIdAtom } from "stores/globalPreferenceStore"; +import { useAtomValue } from "jotai"; // TODO: Create fragment or query for ALL_PRODUCTS_AND_LOCATIONS_FOR_BASE_QUERY export const ALL_PRODUCTS_AND_LOCATIONS_FOR_BASE_QUERY = graphql( @@ -81,15 +81,15 @@ function BoxCreateView() { const navigate = useNavigate(); const { triggerError } = useErrorHandling(); const { createToast } = useNotification(); - const { globalPreferences } = useContext(GlobalPreferencesContext); - const baseName = globalPreferences.selectedBase?.name; + const selectedBase = useAtomValue(selectedBaseAtom); + const baseId = useAtomValue(selectedBaseIdAtom); + const baseName = selectedBase?.name; // no warehouse location or products associated with base const [noLocation, setNoLocation] = useState(false); const [noProducts, setNoProducts] = useState(false); // variables in URL - const { baseId } = useBaseIdParam(); const qrCode = useParams<{ qrCode: string }>().qrCode!; // Query the QR-Code @@ -99,9 +99,7 @@ function BoxCreateView() { // Query Data for the Form const allFormOptions = useQuery(ALL_PRODUCTS_AND_LOCATIONS_FOR_BASE_QUERY, { - variables: { - baseId, - }, + variables: { baseId }, }); // Mutation after form submission diff --git a/front/src/views/BoxEdit/BoxEditView.tsx b/front/src/views/BoxEdit/BoxEditView.tsx index 160c6cbca..61d1625bc 100644 --- a/front/src/views/BoxEdit/BoxEditView.tsx +++ b/front/src/views/BoxEdit/BoxEditView.tsx @@ -1,19 +1,19 @@ import { useEffect } from "react"; import { useMutation, useQuery } from "@apollo/client"; import { graphql } from "../../../../graphql/graphql"; -import APILoadingIndicator from "components/APILoadingIndicator"; import { useNavigate, useParams } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { TAG_OPTIONS_FRAGMENT, PRODUCT_FIELDS_FRAGMENT, BOX_FIELDS_FRAGMENT, } from "queries/fragments"; -// TODO: move to global queries file import { useErrorHandling } from "hooks/useErrorHandling"; import { useNotification } from "hooks/useNotification"; import { BOX_BY_LABEL_IDENTIFIER_AND_ALL_SHIPMENTS_QUERY } from "queries/queries"; +import APILoadingIndicator from "components/APILoadingIndicator"; import BoxEdit, { IBoxEditFormDataOutput } from "./components/BoxEdit"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; export const BOX_BY_LABEL_IDENTIFIER_AND_ALL_PRODUCTS_WITH_BASEID_QUERY = graphql( ` @@ -86,7 +86,7 @@ function BoxEditView() { // variables in URL const labelIdentifier = useParams<{ labelIdentifier: string }>().labelIdentifier!; - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); // Query Data for the Form const allBoxAndFormData = useQuery(BOX_BY_LABEL_IDENTIFIER_AND_ALL_PRODUCTS_WITH_BASEID_QUERY, { diff --git a/front/src/views/BoxEdit/components/BoxEdit.tsx b/front/src/views/BoxEdit/components/BoxEdit.tsx index 8de48032a..c38be9d07 100644 --- a/front/src/views/BoxEdit/components/BoxEdit.tsx +++ b/front/src/views/BoxEdit/components/BoxEdit.tsx @@ -12,13 +12,13 @@ import { import NumberField from "components/Form/NumberField"; import SelectField, { IDropdownOption } from "components/Form/SelectField"; import { useEffect, useRef, useState } from "react"; - +import { useNavigate, useParams } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import _ from "lodash"; -import { useNavigate, useParams } from "react-router-dom"; import { useForm } from "react-hook-form"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; import { ResultOf } from "gql.tada"; import { BOX_BY_LABEL_IDENTIFIER_AND_ALL_PRODUCTS_WITH_BASEID_QUERY } from "../BoxEditView"; import { ProductGender } from "../../../../../graphql/types"; @@ -105,7 +105,7 @@ function BoxEdit({ allTags, onSubmitBoxEditForm, }: IBoxEditProps) { - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const { labelIdentifier } = useParams<{ labelIdentifier: string; }>(); diff --git a/front/src/views/Boxes/BoxesView.tsx b/front/src/views/Boxes/BoxesView.tsx index 5eb29377e..40541d317 100644 --- a/front/src/views/Boxes/BoxesView.tsx +++ b/front/src/views/Boxes/BoxesView.tsx @@ -7,7 +7,6 @@ import { } from "utils/transformers"; import { Column } from "react-table"; import { useTableConfig } from "hooks/hooks"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; import { PRODUCT_BASIC_FIELDS_FRAGMENT, SIZE_BASIC_FIELDS_FRAGMENT, @@ -19,6 +18,7 @@ import BoxesActionsAndTable from "./components/BoxesActionsAndTable"; import { DateCell, DaysCell, ShipmentCell, StateCell, TagsCell } from "./components/TableCells"; import { prepareBoxesForBoxesViewQueryVariables } from "./components/transformers"; import { SelectBoxStateFilter } from "./components/Filter"; +import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; import { BreadcrumbNavigation } from "components/BreadcrumbNavigation"; import { Heading } from "@chakra-ui/react"; @@ -108,7 +108,8 @@ export const ACTION_OPTIONS_FOR_BOXESVIEW_QUERY = graphql( ); function Boxes() { - const { baseId } = useBaseIdParam(); + // using base ID from URL to have it available immediately for the queries + const { urlBaseId: baseId } = useLoadAndSetGlobalPreferences(); const tableConfigKey = `bases/${baseId}/boxes`; const tableConfig = useTableConfig({ diff --git a/front/src/views/Boxes/components/BoxesActionsAndTable.tsx b/front/src/views/Boxes/components/BoxesActionsAndTable.tsx index f14480a9b..13fefe7b6 100644 --- a/front/src/views/Boxes/components/BoxesActionsAndTable.tsx +++ b/front/src/views/Boxes/components/BoxesActionsAndTable.tsx @@ -8,6 +8,7 @@ import { useAssignBoxesToShipment } from "hooks/useAssignBoxesToShipment"; import { useDeleteBoxes } from "hooks/useDeleteBoxes"; import { IBoxBasicFields } from "types/graphql-local-only"; import { Button } from "@chakra-ui/react"; +import { useAtomValue } from "jotai"; import { ShipmentIcon } from "components/Icon/Transfer/ShipmentIcon"; import { useUnassignBoxesFromShipments } from "hooks/useUnassignBoxesFromShipments"; import { useNotification } from "hooks/useNotification"; @@ -16,8 +17,8 @@ import { IUseTableConfigReturnType } from "hooks/hooks"; import { BoxRow } from "./types"; import { SelectButton } from "./ActionButtons"; import BoxesTable from "./BoxesTable"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; import RemoveBoxesButton from "./RemoveBoxesButton"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; import { BoxesForBoxesViewVariables, BoxesForBoxesViewQuery } from "queries/types"; import ExportToCsvButton from "./ExportToCsvButton"; @@ -39,7 +40,7 @@ function BoxesActionsAndTable({ availableColumns, }: IBoxesActionsAndTableProps) { const navigate = useNavigate(); - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const { createToast } = useNotification(); diff --git a/front/src/views/Boxes/components/BoxesTable.tsx b/front/src/views/Boxes/components/BoxesTable.tsx index f22275844..ba1bdd787 100644 --- a/front/src/views/Boxes/components/BoxesTable.tsx +++ b/front/src/views/Boxes/components/BoxesTable.tsx @@ -26,6 +26,7 @@ import { CellProps, } from "react-table"; import { FilteringSortingTableHeader } from "components/Table/TableHeader"; +import { useAtomValue } from "jotai"; import { QueryRef, useReadQuery } from "@apollo/client"; import { includesOneOfMultipleStringsFilterFn, @@ -40,7 +41,7 @@ import { prepareBoxesForBoxesViewQueryVariables, } from "./transformers"; import ColumnSelector from "./ColumnSelector"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; import { BoxesForBoxesViewVariables, BoxesForBoxesViewQuery } from "queries/types"; interface IBoxesTableProps { @@ -64,7 +65,7 @@ function BoxesTable({ setSelectedBoxes, selectedRowsArePending, }: IBoxesTableProps) { - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const [refetchBoxesIsPending, startRefetchBoxes] = useTransition(); const { data: rawData } = useReadQuery(boxesQueryRef); const tableData = useMemo(() => boxesRawDataToTableDataTransformer(rawData), [rawData]); diff --git a/front/src/views/QrReader/components/ResolveHash.tsx b/front/src/views/QrReader/components/ResolveHash.tsx index 0c1f2b45f..6fe6b5927 100644 --- a/front/src/views/QrReader/components/ResolveHash.tsx +++ b/front/src/views/QrReader/components/ResolveHash.tsx @@ -1,14 +1,15 @@ import { IQrResolverResultKind, useQrResolver } from "hooks/useQrResolver"; import { useNavigate, useParams } from "react-router-dom"; import { useEffect, useState } from "react"; +import { useAtomValue } from "jotai"; import { BoxViewSkeleton } from "components/Skeletons"; import QrReaderView from "../QrReaderView"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; function ResolveHash() { const navigate = useNavigate(); const { resolveQrHash } = useQrResolver(); - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); const [showQrScanner, setShowQrScanner] = useState(false); const hash = useParams<{ hash: string }>().hash!; diff --git a/front/src/views/Transfers/CreateShipment/CreateShipmentView.tsx b/front/src/views/Transfers/CreateShipment/CreateShipmentView.tsx index f1803c2e0..b2685db51 100644 --- a/front/src/views/Transfers/CreateShipment/CreateShipmentView.tsx +++ b/front/src/views/Transfers/CreateShipment/CreateShipmentView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; import { graphql } from "gql.tada"; import { Alert, AlertIcon, Center } from "@chakra-ui/react"; @@ -6,7 +6,7 @@ import { useErrorHandling } from "hooks/useErrorHandling"; import { useNotification } from "hooks/useNotification"; import APILoadingIndicator from "components/APILoadingIndicator"; import { useNavigate } from "react-router-dom"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; +import { useAtomValue } from "jotai"; import { BASE_ORG_FIELDS_FRAGMENT, SHIPMENT_FIELDS_FRAGMENT, @@ -17,8 +17,8 @@ import CreateShipment, { IOrganisationBaseData, ICreateShipmentFormData, } from "./components/CreateShipment"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; +import { organisationAtom, selectedBaseIdAtom } from "stores/globalPreferenceStore"; export const ALL_ACCEPTED_TRANSFER_AGREEMENTS_QUERY = graphql( ` @@ -74,11 +74,9 @@ function CreateShipmentView() { const navigate = useNavigate(); const { triggerError } = useErrorHandling(); const { createToast } = useNotification(); - const { globalPreferences } = useContext(GlobalPreferencesContext); const { isLoading: isGlobalStateLoading } = useLoadAndSetGlobalPreferences(); - - // variables in URL - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); + const organisation = useAtomValue(organisationAtom); // Query Data for the Form const allAcceptedTransferAgreements = useQuery(ALL_ACCEPTED_TRANSFER_AGREEMENTS_QUERY, { @@ -117,7 +115,7 @@ function CreateShipmentView() { const currentBase = allAcceptedTransferAgreements?.data?.base; const currentOrganisationName = currentBase?.organisation?.name; const currentOrganisationBase = currentBase?.name; - const currentOrganisationId = globalPreferences.organisation?.id; + const currentOrganisationId = organisation?.id; const [ runAllBasesOfCurrentOrg, @@ -249,10 +247,10 @@ function CreateShipmentView() { }, [ acceptedTransferAgreementsPartnerData, - baseId, + triggerError, createShipmentMutation, + baseId, createToast, - triggerError, navigate, ], ); diff --git a/front/src/views/Transfers/CreateShipment/components/CreateShipment.tsx b/front/src/views/Transfers/CreateShipment/components/CreateShipment.tsx index f38f3e12f..cff63b8d1 100644 --- a/front/src/views/Transfers/CreateShipment/components/CreateShipment.tsx +++ b/front/src/views/Transfers/CreateShipment/components/CreateShipment.tsx @@ -21,13 +21,14 @@ import { } from "@chakra-ui/react"; import { useEffect, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; +import { useAtomValue } from "jotai"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import SelectField, { IDropdownOption } from "components/Form/SelectField"; import { useNavigate } from "react-router-dom"; import { SendingIcon } from "components/Icon/Transfer/SendingIcon"; import { ReceivingIcon } from "components/Icon/Transfer/ReceivingIcon"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom } from "stores/globalPreferenceStore"; export interface IBaseData { id: string; @@ -94,7 +95,7 @@ function CreateShipment({ noAcceptedAgreements, }: ICreateShipmentProps) { const navigate = useNavigate(); - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); // React Hook Form with zod validation const { @@ -219,7 +220,9 @@ function CreateShipment({ PARTNERS - {haveIntraOrganisationOptions && {currentOrganisationName.toUpperCase()}} + {haveIntraOrganisationOptions && ( + {currentOrganisationName.toUpperCase()} + )} organisation.id === userCurrentOrganisationId, @@ -140,11 +142,11 @@ function CreateTransferAgreementView() { // Filter bases where the user is not authorized const currentOrganisationAuthorizedBases = { ...currentOrganisation, - bases: globalPreferences?.availableBases, + bases: availableBases, } as IBasesForOrganisationData; const partnerOrganisationsWithTheirBasesData = allOrgsAndTheirBases?.filter( - (organisation) => organisation.id !== globalPreferences.organisation?.id, + (org) => org.id !== organisation?.id, ); // Handle Submission diff --git a/front/src/views/Transfers/ShipmentView/ShipmentView.tsx b/front/src/views/Transfers/ShipmentView/ShipmentView.tsx index bfd860a15..f0c012d1f 100644 --- a/front/src/views/Transfers/ShipmentView/ShipmentView.tsx +++ b/front/src/views/Transfers/ShipmentView/ShipmentView.tsx @@ -14,11 +14,12 @@ import { useDisclosure, } from "@chakra-ui/react"; import _ from "lodash"; -import { useCallback, useContext, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { useErrorHandling } from "hooks/useErrorHandling"; import { useNotification } from "hooks/useNotification"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; +import { SHIPMENT_FIELDS_FRAGMENT } from "queries/fragments"; import { ButtonSkeleton, ShipmentCardSkeleton, TabsSkeleton } from "components/Skeletons"; import { BoxReconciliationOverlay } from "components/BoxReconciliationOverlay/BoxReconciliationOverlay"; import { UPDATE_SHIPMENT_WHEN_RECEIVING } from "queries/mutations"; @@ -32,9 +33,9 @@ import ShipmentActionButtons from "./components/ShipmentActionButtons"; import ShipmentReceivingContent from "./components/ShipmentReceivingContent"; import ShipmentReceivingCard from "./components/ShipmentReceivingCard"; import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; +import { availableBasesAtom } from "stores/globalPreferenceStore"; import { User } from "../../../../../graphql/types"; import { ShipmentDetail, ShipmentState } from "queries/types"; -import { SHIPMENT_FIELDS_FRAGMENT } from "queries/fragments"; enum ShipmentActionEvent { ShipmentStarted = "Shipment Started", @@ -123,8 +124,8 @@ export const START_RECEIVING_SHIPMENT = graphql( function ShipmentView() { const { triggerError } = useErrorHandling(); - const { globalPreferences } = useContext(GlobalPreferencesContext); const { createToast } = useNotification(); + const availableBases = useAtomValue(availableBasesAtom); const { isOpen: isShipmentOverlayOpen, @@ -490,9 +491,7 @@ function ShipmentView() { shipmentActionButtons = ; } else { isSender = - typeof globalPreferences.availableBases?.find( - (b) => b.id === data?.shipment?.sourceBase?.id, - ) !== "undefined"; + typeof availableBases?.find((b) => b.id === data?.shipment?.sourceBase?.id) !== "undefined"; if ("Preparing" === shipmentState && isSender) { canUpdateShipment = true; diff --git a/front/src/views/Transfers/ShipmentsOverview/ShipmentsOverviewView.tsx b/front/src/views/Transfers/ShipmentsOverview/ShipmentsOverviewView.tsx index 387030b98..0ba2c310f 100644 --- a/front/src/views/Transfers/ShipmentsOverview/ShipmentsOverviewView.tsx +++ b/front/src/views/Transfers/ShipmentsOverview/ShipmentsOverviewView.tsx @@ -1,8 +1,8 @@ -import { useContext, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery } from "@apollo/client"; import { Alert, AlertIcon, Button, Heading, Stack, Tab, TabList, Tabs } from "@chakra-ui/react"; import { Link, useLocation } from "react-router-dom"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; +import { useAtomValue } from "jotai"; import { ALL_SHIPMENTS_QUERY } from "queries/queries"; import { AddIcon } from "@chakra-ui/icons"; import { compareDesc } from "date-fns"; @@ -12,7 +12,7 @@ import { SelectColumnFilter } from "components/Table/Filter"; import { BreadcrumbNavigation } from "components/BreadcrumbNavigation"; import { BaseOrgCell, BoxesCell, StateCell } from "./components/TableCells"; import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; +import { selectedBaseIdAtom, selectedBaseAtom } from "stores/globalPreferenceStore"; import { SendingIcon } from "components/Icon/Transfer/SendingIcon"; import { ReceivingIcon } from "components/Icon/Transfer/ReceivingIcon"; import { ShipmentState } from "queries/types"; @@ -35,12 +35,12 @@ type ShipmentRow = | undefined; function ShipmentsOverviewView() { - const { globalPreferences } = useContext(GlobalPreferencesContext); const { isLoading: isGlobalStateLoading } = useLoadAndSetGlobalPreferences(); - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); + const selectedBase = useAtomValue(selectedBaseAtom); // If forwarded from AgreementsOverview const location = useLocation(); - const currentBaseName = globalPreferences.selectedBase?.name || ""; + const currentBaseName = selectedBase?.name || ""; const [direction, setDirection] = useState<"Receiving" | "Sending">("Receiving"); // fetch shipments data diff --git a/front/src/views/Transfers/TransferAgreementOverview/TransferAgreementOverviewView.tsx b/front/src/views/Transfers/TransferAgreementOverview/TransferAgreementOverviewView.tsx index 729d8e8cb..9a5bc9202 100644 --- a/front/src/views/Transfers/TransferAgreementOverview/TransferAgreementOverviewView.tsx +++ b/front/src/views/Transfers/TransferAgreementOverview/TransferAgreementOverviewView.tsx @@ -1,9 +1,10 @@ -import { useCallback, useContext, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useMutation, useQuery } from "@apollo/client"; -import { graphql, ResultOf } from "../../../../../graphql/graphql"; import { Alert, AlertIcon, Button, Heading, Stack, useDisclosure } from "@chakra-ui/react"; import { Link } from "react-router-dom"; -import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider"; +import { useAtomValue } from "jotai"; +import { TRANSFER_AGREEMENT_FIELDS_FRAGMENT } from "queries/fragments"; +import { graphql, ResultOf } from "../../../../../graphql/graphql"; import { AddIcon } from "@chakra-ui/icons"; import { TableSkeleton } from "components/Skeletons"; import { Row } from "react-table"; @@ -21,10 +22,13 @@ import { } from "./components/TableCells"; import TransferAgreementsOverlay from "./components/TransferAgreementOverlay"; import { ALL_ACCEPTED_TRANSFER_AGREEMENTS_QUERY } from "../CreateShipment/CreateShipmentView"; -import { useBaseIdParam } from "hooks/useBaseIdParam"; -import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; +import { + organisationAtom, + availableBasesAtom, + selectedBaseIdAtom, +} from "stores/globalPreferenceStore"; import { TransferAgreements } from "queries/types"; -import { TRANSFER_AGREEMENT_FIELDS_FRAGMENT } from "queries/fragments"; +import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences"; export interface IAcceptedTransferAgreement { transferAgreements: TransferAgreements; @@ -84,11 +88,10 @@ interface IShipmentBase { function TransferAgreementOverviewView() { const { triggerError } = useErrorHandling(); const { createToast } = useNotification(); - const { globalPreferences } = useContext(GlobalPreferencesContext); const { isLoading: isGlobalStateLoading } = useLoadAndSetGlobalPreferences(); - - // variables in URL - const { baseId } = useBaseIdParam(); + const baseId = useAtomValue(selectedBaseIdAtom); + const organisation = useAtomValue(organisationAtom); + const availableBases = useAtomValue(availableBasesAtom); const { isOpen, onClose, onOpen } = useDisclosure(); // State to pass Data from a row to the Overlay @@ -111,7 +114,9 @@ function TransferAgreementOverviewView() { if (returnedTransferAgreement?.acceptTransferAgreement) { const acceptedTransferAgreement = returnedTransferAgreement?.acceptTransferAgreement; - const existingAcceptedTransferAgreementsData = cache.readQuery({ + const existingAcceptedTransferAgreementsData = cache.readQuery< + ResultOf + >({ query: ALL_ACCEPTED_TRANSFER_AGREEMENTS_QUERY, variables: { baseId }, }); @@ -156,7 +161,9 @@ function TransferAgreementOverviewView() { const cancelledTransferAgreementId = returnedTransferAgreement?.cancelTransferAgreement.id; - const existingAcceptedTransferAgreementsData = cache.readQuery({ + const existingAcceptedTransferAgreementsData = cache.readQuery< + ResultOf + >({ query: ALL_ACCEPTED_TRANSFER_AGREEMENTS_QUERY, variables: { baseId }, }); @@ -228,8 +235,8 @@ function TransferAgreementOverviewView() { transferAgreementQueryResult?: ResultOf, ) => transferAgreementQueryResult?.transferAgreements.map((element) => { - if (globalPreferences.organisation !== undefined) { - const currentOrgId = parseInt(globalPreferences.organisation.id, 10); + if (organisation) { + const currentOrgId = parseInt(organisation?.id, 10); const sourceOrgId = parseInt(element.sourceOrganisation.id, 10); const targetOrgId = parseInt(element.targetOrganisation.id, 10); @@ -283,20 +290,16 @@ function TransferAgreementOverviewView() { (shipment.state === "Preparing" || shipment.state === "Sent" || shipment.state === "Receiving") && - globalPreferences.availableBases !== undefined + availableBases !== undefined ) { if ( shipment.targetBase != null && - globalPreferences.availableBases.findIndex( - ({ id }) => shipment.targetBase?.id === id, - ) === -1 + availableBases.findIndex(({ id }) => shipment.targetBase?.id === id) === -1 ) { shipmentsTmp.push(shipment.targetBase); } else if ( shipment.sourceBase != null && - globalPreferences.availableBases.findIndex( - ({ id }) => shipment.sourceBase?.id === id, - ) === -1 + availableBases.findIndex(({ id }) => shipment.sourceBase?.id === id) === -1 ) { shipmentsTmp.push(shipment.sourceBase); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38adb57ee..90f7f7e44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: chakra-react-select: specifier: 4.9.2 version: 4.9.2(h4rtezw7g3xpj2ip2isedll5nm) + jotai: + specifier: ^2.10.3 + version: 2.10.4(@types/react@18.3.14)(react@18.3.1) react-big-calendar: specifier: ^1.17.0 version: 1.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3546,6 +3549,18 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jotai@2.10.4: + resolution: {integrity: sha512-/T4ofyMSkAybEs2OvR8S4HACa+/ASUEPLz86SUjFXJqU9RdJKLvZDJrag398suvHC5CR0+Cs4P5m/gtVcryzlw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8819,6 +8834,11 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jotai@2.10.4(@types/react@18.3.14)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.14 + react: 18.3.1 + js-tokens@4.0.0: {} js-yaml@4.1.0: