diff --git a/src/App.tsx b/src/App.tsx index c91849de559..b4d1a1570a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,8 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + QueryCache, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { Suspense } from "react"; @@ -12,17 +16,21 @@ import AuthUserProvider from "@/Providers/AuthUserProvider"; import HistoryAPIProvider from "@/Providers/HistoryAPIProvider"; import Routers from "@/Routers"; import { FeatureFlagsProvider } from "@/Utils/featureFlags"; +import { handleQueryError } from "@/Utils/request/errorHandler"; import { PubSubProvider } from "./Utils/pubsubContext"; const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: 3, + retry: 2, refetchOnWindowFocus: false, staleTime: 5 * 60 * 1000, // 5 minutes }, }, + queryCache: new QueryCache({ + onError: handleQueryError, + }), }); const App = () => { diff --git a/src/Utils/request/README.md b/src/Utils/request/README.md index 0780d3149f6..3c1279e1554 100644 --- a/src/Utils/request/README.md +++ b/src/Utils/request/README.md @@ -4,41 +4,108 @@ CARE now uses TanStack Query (formerly React Query) as its data fetching solutio ## Using TanStack Query (Recommended for new code) -For new API integrations, we recommend using TanStack Query directly: +For new API integrations, we recommend using TanStack Query with `query` utility function. This is a wrapper around `fetch` that works seamlessly with TanStack Query. It handles response parsing, error handling, setting headers, and more. ```tsx import { useQuery } from "@tanstack/react-query"; -import request from "@/Utils/request/request"; -import FooRoutes from "@foo/routes"; +import query from "@/Utils/request/query"; -export default function FooDetails({ id }) { - const { data, isLoading, error } = useQuery({ - queryKey: [FooRoutes.getFoo.path, id], - queryFn: async () => { - const response = await request(FooRoutes.getFoo, { - pathParams: { id } - }); - return response.data; - } +export default function UserProfile() { + const { data, isLoading } = useQuery({ + queryKey: [routes.users.current.path], + queryFn: query(routes.users.current) }); if (isLoading) return ; - if (error) return ; + return
{data?.name}
; +} - return ( -
- {data.id} - {data.name} -
- ); +// With path parameters +function PatientDetails({ id }: { id: string }) { + const { data } = useQuery({ + queryKey: ['patient', id], + queryFn: query(routes.patient.get, { + pathParams: { id } + }) + }); + + return
{data?.name}
; +} + +// With query parameters +function SearchMedicines() { + const { data } = useQuery({ + queryKey: ['medicines', 'paracetamol'], + queryFn: query(routes.medicine.search, { + queryParams: { search: 'paracetamol' } + }) + }); + + return ; +} + +// When you need response status/error handling +function FacilityDetails({ id }: { id: string }) { + const { data, isLoading } = useQuery({ + queryKey: ["facility", id], + queryFn: query(routes.getFacility, { + pathParams: { id }, + silent: true + }) + }); + + if (isLoading) return ; + return
{data?.name}
; +} + +### query + +`query` is our wrapper around fetch that works seamlessly with TanStack Query. It: +- Handles response parsing (JSON, text, blobs). +- Constructs proper error objects. +- Sets the headers appropriately. +- Integrates with our global error handling. + +```typescript +interface QueryOptions { + pathParams?: Record; // URL parameters + queryParams?: Record; // Query string parameters + silent?: boolean; // Suppress error notifications } + +// Basic usage +useQuery({ + queryKey: ["users"], + queryFn: query(routes.users.list) +}); + +// With parameters +useQuery({ + queryKey: ["user", id], + queryFn: query(routes.users.get, { + pathParams: { id }, + queryParams: { include: "details" }, + silent: true // Optional: suppress error notifications + }) +}); ``` +### Error Handling + +All API errors are now handled globally. Common scenarios like: + +- Session expiry -> Redirects to /session-expired +- Bad requests (400/406) -> Shows error notification +are automatically handled. + +Use the `silent: true` option to suppress error notifications for specific queries. + ## Migration Guide & Reference ### Understanding the Transition Our codebase contains two patterns for data fetching: + 1. Legacy pattern using `useTanStackQueryInstead` (wrapper around TanStack Query) 2. Modern pattern using TanStack Query directly @@ -60,12 +127,9 @@ function LegacyComponent({ id }) { function ModernComponent({ id }) { const { data, isLoading, error, refetch } = useQuery({ queryKey: [UserRoutes.getUser.path, id], - queryFn: async () => { - const response = await request(UserRoutes.getUser, { - pathParams: { id } - }); - return response.data; - }, + queryFn: query(UserRoutes.getUser, { + pathParams: { id } + }), enabled: true, refetchOnWindowFocus: false }); @@ -100,7 +164,7 @@ useTanStackQueryInstead(route, { prefetch: shouldFetch }) // Modern useQuery({ queryKey: [route.path], - queryFn: async () => (await request(route)).data, + queryFn: query(route), enabled: shouldFetch }) ``` @@ -116,13 +180,10 @@ useTanStackQueryInstead(route, { // Modern useQuery({ queryKey: [route.path, id, filter], - queryFn: async () => { - const response = await request(route, { - pathParams: { id }, - query: { filter } - }); - return response.data; - } + queryFn: query(route, { + pathParams: { id }, + queryParams: { filter } + }) }) ``` @@ -135,12 +196,10 @@ if (res?.status === 403) handleForbidden(); // Modern useQuery({ queryKey: [route.path], - queryFn: async () => { - const response = await request(route); - if (response.res.status === 403) handleForbidden(); - return response.data; - }, - onError: (error) => handleError(error) + queryFn: query(route, { + silent: true // Optional: suppress error notifications + }) + // Error handling is now done globally }) ``` diff --git a/src/Utils/request/errorHandler.ts b/src/Utils/request/errorHandler.ts new file mode 100644 index 00000000000..68d7e4600bb --- /dev/null +++ b/src/Utils/request/errorHandler.ts @@ -0,0 +1,54 @@ +import { navigate } from "raviger"; + +import * as Notifications from "@/Utils/Notifications"; +import { QueryError } from "@/Utils/request/queryError"; + +export function handleQueryError(error: Error) { + if (error.name === "AbortError") { + return; + } + + if (!(error instanceof QueryError)) { + Notifications.Error({ msg: error.message || "Something went wrong!" }); + return; + } + + if (error.silent) { + return; + } + + const cause = error.cause; + + if (isSessionExpired(cause)) { + handleSessionExpired(); + return; + } + + if (isBadRequest(error)) { + Notifications.BadRequest({ errs: cause }); + return; + } + + Notifications.Error({ + msg: cause?.detail || "Something went wrong...!", + }); +} + +function isSessionExpired(error: QueryError["cause"]) { + return ( + // If Authorization header is not valid + error?.code === "token_not_valid" || + // If Authorization header is not provided + error?.detail === "Authentication credentials were not provided." + ); +} + +function handleSessionExpired() { + if (!location.pathname.startsWith("/session-expired")) { + navigate(`/session-expired?redirect=${window.location.href}`); + } +} + +function isBadRequest(error: QueryError) { + return error.status === 400 || error.status === 406; +} diff --git a/src/Utils/request/query.ts b/src/Utils/request/query.ts new file mode 100644 index 00000000000..3431f625728 --- /dev/null +++ b/src/Utils/request/query.ts @@ -0,0 +1,56 @@ +import careConfig from "@careConfig"; + +import { QueryError } from "@/Utils/request/queryError"; +import { getResponseBody } from "@/Utils/request/request"; +import { QueryOptions, Route } from "@/Utils/request/types"; +import { makeHeaders, makeUrl } from "@/Utils/request/utils"; + +async function queryRequest( + { path, method, noAuth }: Route, + options?: QueryOptions, +): Promise { + const url = `${careConfig.apiUrl}${makeUrl(path, options?.queryParams, options?.pathParams)}`; + + const fetchOptions: RequestInit = { + method, + headers: makeHeaders(noAuth ?? false), + signal: options?.signal, + }; + + if (options?.body) { + fetchOptions.body = JSON.stringify(options.body); + } + + let res: Response; + + try { + res = await fetch(url, fetchOptions); + } catch { + throw new Error("Network Error"); + } + + const data = await getResponseBody(res); + + if (!res.ok) { + throw new QueryError({ + message: "Request Failed", + status: res.status, + silent: options?.silent ?? false, + cause: data as unknown as Record, + }); + } + + return data; +} + +/** + * Creates a TanStack Query compatible request function + */ +export default function query( + route: Route, + options?: QueryOptions, +) { + return ({ signal }: { signal: AbortSignal }) => { + return queryRequest(route, { ...options, signal }); + }; +} diff --git a/src/Utils/request/queryError.ts b/src/Utils/request/queryError.ts new file mode 100644 index 00000000000..cdfad312ef4 --- /dev/null +++ b/src/Utils/request/queryError.ts @@ -0,0 +1,24 @@ +type QueryErrorCause = Record | undefined; + +export class QueryError extends Error { + status: number; + silent: boolean; + cause?: QueryErrorCause; + + constructor({ + message, + status, + silent, + cause, + }: { + message: string; + status: number; + silent: boolean; + cause?: Record; + }) { + super(message, { cause }); + this.status = status; + this.silent = silent; + this.cause = cause; + } +} diff --git a/src/Utils/request/request.ts b/src/Utils/request/request.ts index 73bc9763f50..bd1cabc523c 100644 --- a/src/Utils/request/request.ts +++ b/src/Utils/request/request.ts @@ -61,7 +61,7 @@ export default async function request( return result; } -async function getResponseBody(res: Response): Promise { +export async function getResponseBody(res: Response): Promise { if (!(res.headers.get("content-length") !== "0")) { return null as TData; } diff --git a/src/Utils/request/types.ts b/src/Utils/request/types.ts index 668f937dbf0..20f095ae6fd 100644 --- a/src/Utils/request/types.ts +++ b/src/Utils/request/types.ts @@ -35,6 +35,14 @@ export interface RequestOptions { silent?: boolean; } +export interface QueryOptions { + pathParams?: Record; + queryParams?: Record; + body?: TBody; + silent?: boolean; + signal?: AbortSignal; +} + export interface PaginatedResponse { count: number; next: string | null;