diff --git a/src/CAREUI/misc/PaginatedList.tsx b/src/CAREUI/misc/PaginatedList.tsx new file mode 100644 index 00000000000..28ee17650ad --- /dev/null +++ b/src/CAREUI/misc/PaginatedList.tsx @@ -0,0 +1,167 @@ +import { createContext, useContext, useState } from "react"; +import { PaginatedResponse, QueryRoute } from "../../Utils/request/types"; +import useQuery, { QueryOptions } from "../../Utils/request/useQuery"; +import ButtonV2, { + CommonButtonProps, +} from "../../Components/Common/components/ButtonV2"; +import CareIcon from "../icons/CareIcon"; +import { classNames } from "../../Utils/utils"; +import Pagination from "../../Components/Common/Pagination"; + +const DEFAULT_PER_PAGE_LIMIT = 14; + +interface PaginatedListContext + extends ReturnType>> { + items: TItem[]; + perPage: number; + currentPage: number; + setPage: (page: number) => void; +} + +const context = createContext | null>(null); + +function useContextualized() { + const ctx = useContext(context); + + if (ctx === null) { + throw new Error("PaginatedList must be used within a PaginatedList"); + } + + return ctx as PaginatedListContext; +} + +interface Props extends QueryOptions { + route: QueryRoute>; + perPage?: number; + children: (ctx: PaginatedListContext) => JSX.Element | JSX.Element[]; +} + +export default function PaginatedList({ + children, + route, + perPage = DEFAULT_PER_PAGE_LIMIT, + ...queryOptions +}: Props) { + const query = useQuery(route, { + ...queryOptions, + query: { ...queryOptions.query, limit: perPage }, + }); + const [currentPage, setPage] = useState(1); + + const items = query.data?.results ?? []; + + return ( + + + {(ctx) => children(ctx as PaginatedListContext)} + + + ); +} + +interface WhenEmptyProps { + className?: string; + children: JSX.Element | JSX.Element[]; +} + +const WhenEmpty = (props: WhenEmptyProps) => { + const { items, loading } = useContextualized(); + + if (loading || items.length > 0) { + return null; + } + + return
{props.children}
; +}; + +PaginatedList.WhenEmpty = WhenEmpty; + +const WhenLoading = (props: WhenEmptyProps) => { + const { loading } = useContextualized(); + + if (!loading) { + return null; + } + + return
{props.children}
; +}; + +PaginatedList.WhenLoading = WhenLoading; + +const Refresh = ({ label = "Refresh", ...props }: CommonButtonProps) => { + const { loading, refetch } = useContextualized(); + + return ( + refetch()} + disabled={loading} + > + + {label} + + ); +}; + +PaginatedList.Refresh = Refresh; + +interface ItemsProps { + className?: string; + children: (item: TItem) => JSX.Element | JSX.Element[]; + shimmer?: JSX.Element; + shimmerCount?: number; +} + +const Items = (props: ItemsProps) => { + const { loading, items } = useContextualized(); + + return ( +
    + {loading && props.shimmer + ? Array.from({ length: props.shimmerCount ?? 8 }).map((_, i) => ( +
  • + {props.shimmer} +
  • + )) + : items.map((item, index) => ( +
  • + {props.children(item)} +
  • + ))} +
+ ); +}; + +PaginatedList.Items = Items; + +interface PaginatorProps { + className?: string; + hideIfSinglePage?: boolean; +} + +const Paginator = ({ className, hideIfSinglePage }: PaginatorProps) => { + const { data, perPage, currentPage, setPage } = useContextualized(); + + if (hideIfSinglePage && (data?.count ?? 0) <= perPage) { + return null; + } + + return ( + + ); +}; + +PaginatedList.Paginator = Paginator; diff --git a/src/Components/Common/components/ButtonV2.tsx b/src/Components/Common/components/ButtonV2.tsx index 4c09445217d..2f3d3002451 100644 --- a/src/Components/Common/components/ButtonV2.tsx +++ b/src/Components/Common/components/ButtonV2.tsx @@ -161,7 +161,7 @@ export default ButtonV2; // Common buttons -type CommonButtonProps = ButtonProps & { label?: string }; +export type CommonButtonProps = ButtonProps & { label?: string }; export const Submit = ({ label = "Submit", ...props }: CommonButtonProps) => { const { t } = useTranslation(); diff --git a/src/Components/Facility/LocationManagement.tsx b/src/Components/Facility/LocationManagement.tsx index 01e0c246ecb..38dcfc1f389 100644 --- a/src/Components/Facility/LocationManagement.tsx +++ b/src/Components/Facility/LocationManagement.tsx @@ -1,176 +1,99 @@ -import { useCallback, useState, ReactElement, lazy } from "react"; - -import { useDispatch } from "react-redux"; -import { statusType, useAbortableEffect } from "../../Common/utils"; -import { listFacilityAssetLocation, getAnyFacility } from "../../Redux/actions"; -import Pagination from "../Common/Pagination"; -import { LocationModel } from "./models"; +import { lazy } from "react"; import ButtonV2 from "../Common/components/ButtonV2"; import { NonReadOnlyUsers } from "../../Utils/AuthorizeFor"; import CareIcon from "../../CAREUI/icons/CareIcon"; import Page from "../Common/components/Page"; -const Loading = lazy(() => import("../Common/Loading")); +import routes from "../../Redux/api"; +import PaginatedList from "../../CAREUI/misc/PaginatedList"; +import { LocationModel } from "./models"; -interface LocationManagementProps { - facilityId: string; -} +const Loading = lazy(() => import("../Common/Loading")); -interface LocationRowProps { - id: string; +interface Props { facilityId: string; - name: string; - description: string; } -const LocationRow = (props: LocationRowProps) => { - const { id, facilityId, name, description } = props; - +export default function LocationManagement({ facilityId }: Props) { return ( -
-
-
-

{name}

-

{description}

-
-
-
- - - Edit - - ( + + + Add New Location + + } > - - Manage Beds - -
-
- ); -}; - -export const LocationManagement = (props: LocationManagementProps) => { - const { facilityId } = props; - const dispatchAction: any = useDispatch(); - const [isLoading, setIsLoading] = useState(false); - let location: ReactElement | null = null; - let locationsList: ReactElement[] | ReactElement = []; - const [locations, setLocations] = useState([]); - const [offset, setOffset] = useState(0); - const [currentPage, setCurrentPage] = useState(1); - const [totalCount, setTotalCount] = useState(0); - const [facilityName, setFacilityName] = useState(""); - const limit = 14; - - const fetchData = useCallback( - async (status: statusType) => { - setIsLoading(true); - const facility = await dispatchAction(getAnyFacility(facilityId)); - - setFacilityName(facility?.data?.name || ""); - - const res = await dispatchAction( - listFacilityAssetLocation( - { limit, offset }, - { facility_external_id: facilityId } - ) - ); - if (!status.aborted) { - if (res?.data) { - setLocations(res.data.results); - setTotalCount(res.data.count); - } - setIsLoading(false); - } - }, - [dispatchAction, offset, facilityId] - ); - - useAbortableEffect( - (status: statusType) => { - fetchData(status); - }, - [fetchData] - ); +
+ + + Add New Location + +
+ + No locations available + - const handlePagination = (page: number, limit: number) => { - const offset = (page - 1) * limit; - setCurrentPage(page); - setOffset(offset); - }; + + + - if (locations?.length) { - locationsList = locations.map((locationItem: LocationModel) => ( - - )); - } else if (locations && locations.length === 0) { - locationsList = ( -

- No locations available -

- ); - } + className="my-8 flex grow flex-col gap-3 lg:mx-8"> + {(item) => } + - if (locations) { - location = ( - <> -
- {locationsList} -
- {totalCount > limit && ( -
- +
+
- )} - - ); - } - - if (isLoading || !locations) { - return ; - } + + )} + + ); +} - return ( - -
-
- - - Add New Location - -
- {location} +const Location = ({ name, description, id }: LocationModel) => ( +
+
+
+

{name}

+

{description}

- - ); -}; +
+ +
+ + + Edit + + + + Manage Beds + +
+
+); diff --git a/src/Redux/api.tsx b/src/Redux/api.tsx index 01a91d4ce3a..b98a099f439 100644 --- a/src/Redux/api.tsx +++ b/src/Redux/api.tsx @@ -1,14 +1,15 @@ -interface Route { - path: string; - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - noAuth?: boolean; +import { LocationModel } from "../Components/Facility/models"; +import { PaginatedResponse } from "../Utils/request/types"; + +/** + * A fake function that returns an empty object casted to type T + * @returns Empty object as type T + */ +function Res(): T { + return {} as T; } -interface Routes { - [name: string]: Route; -} - -const routes: Routes = { +const routes = { config: { path: import.meta.env.REACT_APP_CONFIG ?? "/config.json", method: "GET", @@ -187,6 +188,7 @@ const routes: Routes = { listFacilityAssetLocation: { path: "/api/v1/facility/{facility_external_id}/asset_location/", method: "GET", + TRes: Res>(), }, createFacilityAssetLocation: { path: "/api/v1/facility/{facility_external_id}/asset_location/", @@ -1065,6 +1067,6 @@ const routes: Routes = { path: "/api/v1/hcx/make_claim/", method: "POST", }, -}; +} as const; export default routes; diff --git a/src/Router/AppRouter.tsx b/src/Router/AppRouter.tsx index 24b24cfdf09..f1449f13bc2 100644 --- a/src/Router/AppRouter.tsx +++ b/src/Router/AppRouter.tsx @@ -49,7 +49,7 @@ import ShowPushNotification from "../Components/Notifications/ShowPushNotificati import { NoticeBoard } from "../Components/Notifications/NoticeBoard"; import { AddLocationForm } from "../Components/Facility/AddLocationForm"; import { AddBedForm } from "../Components/Facility/AddBedForm"; -import { LocationManagement } from "../Components/Facility/LocationManagement"; +import LocationManagement from "../Components/Facility/LocationManagement"; import { BedManagement } from "../Components/Facility/BedManagement"; import AssetsList from "../Components/Assets/AssetsList"; import AssetManage from "../Components/Assets/AssetManage"; diff --git a/src/Utils/request/request.ts b/src/Utils/request/request.ts new file mode 100644 index 00000000000..2dc938fa6f1 --- /dev/null +++ b/src/Utils/request/request.ts @@ -0,0 +1,27 @@ +import { RequestOptions, Route } from "./types"; +import { makeHeaders, makeUrl } from "./utils"; + +interface Options extends RequestOptions { + controller?: AbortController; +} + +export default async function request( + { path, method, noAuth }: Route, + { query, body, pathParams, controller }: Options = {} +) { + const signal = controller?.signal; + + const headers = makeHeaders(noAuth ?? false); + const url = makeUrl(path, query, pathParams); + + const options: RequestInit = { headers, method, signal }; + + if (body) { + options.body = JSON.stringify(body); + } + + const res = await fetch(url, options); + const data: TData = await res.json(); + + return { res, data }; +} diff --git a/src/Utils/request/types.ts b/src/Utils/request/types.ts new file mode 100644 index 00000000000..e7f0f9544a3 --- /dev/null +++ b/src/Utils/request/types.ts @@ -0,0 +1,32 @@ +type QueryParamValue = string | number | boolean | null | undefined; + +export type QueryParams = Record; + +interface RouteBase { + path: string; + TRes: TData; + noAuth?: boolean; +} + +export interface QueryRoute extends RouteBase { + method?: "GET"; +} + +export interface MutationRoute extends RouteBase { + method: "POST" | "PUT" | "PATCH" | "DELETE"; +} + +export type Route = QueryRoute | MutationRoute; + +export interface RequestOptions { + query?: QueryParams; + body?: object; + pathParams?: Record; +} + +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: TItem[]; +} diff --git a/src/Utils/request/useQuery.ts b/src/Utils/request/useQuery.ts new file mode 100644 index 00000000000..e459a579e3e --- /dev/null +++ b/src/Utils/request/useQuery.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { QueryRoute, RequestOptions } from "./types"; +import request from "./request"; +import { mergeRequestOptions } from "./utils"; + +export interface QueryOptions extends RequestOptions { + prefetch?: boolean; + refetchOnWindowFocus?: boolean; +} + +export default function useQuery( + route: QueryRoute, + options?: QueryOptions +) { + const [res, setRes] = useState(); + const [data, setData] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + + const controllerRef = useRef(); + + const runQuery = useCallback( + async (overrides?: QueryOptions) => { + controllerRef.current?.abort(); + + const controller = new AbortController(); + controllerRef.current = controller; + + const resolvedOptions = + options && overrides + ? mergeRequestOptions(options, overrides) + : options; + + setLoading(true); + + try { + const { res, data } = await request(route, resolvedOptions); + + setRes(res); + setData(res.ok ? data : undefined); + setError(res.ok ? undefined : data); + } catch (error) { + console.error(error); + setData(undefined); + setError(error); + } finally { + setLoading(false); + } + }, + [route, JSON.stringify(options)] + ); + + useEffect(() => { + if (options?.prefetch ?? true) { + runQuery(); + } + }, [runQuery, options?.prefetch]); + + useEffect(() => { + if (options?.refetchOnWindowFocus) { + const onFocus = () => runQuery(); + + window.addEventListener("focus", onFocus); + + return () => window.removeEventListener("focus", onFocus); + } + }, [runQuery, options?.refetchOnWindowFocus]); + + return { res, data, error, loading, refetch: runQuery }; +} diff --git a/src/Utils/request/utils.ts b/src/Utils/request/utils.ts new file mode 100644 index 00000000000..21236a8145f --- /dev/null +++ b/src/Utils/request/utils.ts @@ -0,0 +1,81 @@ +import { LocalStorageKeys } from "../../Common/constants"; +import { QueryParams, RequestOptions } from "./types"; + +export function makeUrl( + path: string, + query?: QueryParams, + pathParams?: Record +) { + if (pathParams) { + path = Object.entries(pathParams).reduce( + (acc, [key, value]) => acc.replace(`{${key}}`, value), + path + ); + } + + ensurePathNotMissingReplacements(path); + + if (query) { + path += `?${makeQueryParams(query)}`; + } + + return path; +} + +const makeQueryParams = (query: QueryParams) => { + const qParams = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined) { + qParams.set(key, `${value}`); + } + }); + + return qParams.toString(); +}; + +const ensurePathNotMissingReplacements = (path: string) => { + const missingParams = path.match(/\{.*\}/g); + + if (missingParams) { + throw new Error(`Missing path params: ${missingParams.join(", ")}`); + } +}; + +export function makeHeaders(noAuth: boolean) { + const headers = new Headers({ + "Content-Type": "application/json", + Accept: "application/json", + }); + + if (!noAuth) { + const token = getAuthorizationHeader(); + + if (token) { + headers.append("Authorization", token); + } + } + + return headers; +} + +export function getAuthorizationHeader() { + const bearerToken = localStorage.getItem(LocalStorageKeys.accessToken); + + if (bearerToken) { + return `Bearer ${bearerToken}`; + } + + return null; +} + +export function mergeRequestOptions( + options: RequestOptions, + overrides: RequestOptions +): RequestOptions { + return { + query: { ...options.query, ...overrides.query }, + body: { ...options.body, ...overrides.body }, + pathParams: { ...options.pathParams, ...overrides.pathParams }, + }; +}