diff --git a/.cursorrules b/.cursorrules index 94ebea1bd57..84ed409d44b 100644 --- a/.cursorrules +++ b/.cursorrules @@ -31,5 +31,5 @@ UI and Styling General Guidelines -- Care uses a custom useQuery hook to fetch data from the API. (Docs @ /Utils/request/useQuery) +- Care uses TanStack Query for data fetching from the API along with query and mutate utilities for the queryFn and mutationFn. (Docs @ /Utils/request/README.md) - APIs are defined in the api.tsx file. diff --git a/src/App.tsx b/src/App.tsx index b4d1a1570a9..1d8acbb8e59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { + MutationCache, QueryCache, QueryClient, QueryClientProvider, @@ -16,7 +17,7 @@ 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 { handleHttpError } from "@/Utils/request/errorHandler"; import { PubSubProvider } from "./Utils/pubsubContext"; @@ -29,7 +30,10 @@ const queryClient = new QueryClient({ }, }, queryCache: new QueryCache({ - onError: handleQueryError, + onError: handleHttpError, + }), + mutationCache: new MutationCache({ + onError: handleHttpError, }), }); diff --git a/src/Utils/request/README.md b/src/Utils/request/README.md index 3c1279e1554..75dfab6c10f 100644 --- a/src/Utils/request/README.md +++ b/src/Utils/request/README.md @@ -67,10 +67,12 @@ function FacilityDetails({ id }: { id: string }) { - Integrates with our global error handling. ```typescript -interface QueryOptions { +interface APICallOptions { pathParams?: Record; // URL parameters - queryParams?: Record; // Query string parameters + queryParams?: QueryParams; // Query string parameters + body?: TBody; // Request body silent?: boolean; // Suppress error notifications + headers?: HeadersInit; // Additional headers } // Basic usage @@ -100,6 +102,82 @@ are automatically handled. Use the `silent: true` option to suppress error notifications for specific queries. +## Using Mutations with TanStack Query + +For data mutations, we provide a `mutate` utility that works seamlessly with TanStack Query's `useMutation` hook. + +```tsx +import { useMutation } from "@tanstack/react-query"; +import mutate from "@/Utils/request/mutate"; + +function CreatePrescription({ consultationId }: { consultationId: string }) { + const { mutate: createPrescription, isPending } = useMutation({ + mutationFn: mutate(MedicineRoutes.createPrescription, { + pathParams: { consultationId }, + }), + onSuccess: () => { + toast.success("Prescription created successfully"); + }, + }); + + return ( + + ); +} + +// With path parameters and complex payload +function UpdatePatient({ patientId }: { patientId: string }) { + const { mutate: updatePatient } = useMutation({ + mutationFn: mutate(PatientRoutes.update, { + pathParams: { id: patientId }, + silent: true // Optional: suppress error notifications + }) + }); + + const handleSubmit = (data: PatientData) => { + updatePatient(data); + }; + + return ; +} +``` + +### mutate + +`mutate` is our wrapper around the API call functionality that works with TanStack Query's `useMutation`. It: +- Handles request body serialization +- Sets appropriate headers +- Integrates with our global error handling +- Provides TypeScript type safety for your mutation payload + +```typescript +interface APICallOptions { + pathParams?: Record; // URL parameters + queryParams?: QueryParams; // Query string parameters + body?: TBody; // Request body + silent?: boolean; // Suppress error notifications + headers?: HeadersInit; // Additional headers +} + +// Basic usage +useMutation({ + mutationFn: mutate(routes.users.create) +}); + +// With parameters +useMutation({ + mutationFn: mutate(routes.users.update, { + pathParams: { id }, + silent: true // Optional: suppress error notifications + }) +}); +``` + ## Migration Guide & Reference ### Understanding the Transition diff --git a/src/Utils/request/errorHandler.ts b/src/Utils/request/errorHandler.ts index 68d7e4600bb..c5609181f13 100644 --- a/src/Utils/request/errorHandler.ts +++ b/src/Utils/request/errorHandler.ts @@ -1,14 +1,14 @@ import { navigate } from "raviger"; import * as Notifications from "@/Utils/Notifications"; -import { QueryError } from "@/Utils/request/queryError"; +import { HTTPError } from "@/Utils/request/types"; -export function handleQueryError(error: Error) { +export function handleHttpError(error: Error) { if (error.name === "AbortError") { return; } - if (!(error instanceof QueryError)) { + if (!(error instanceof HTTPError)) { Notifications.Error({ msg: error.message || "Something went wrong!" }); return; } @@ -34,7 +34,7 @@ export function handleQueryError(error: Error) { }); } -function isSessionExpired(error: QueryError["cause"]) { +function isSessionExpired(error: HTTPError["cause"]) { return ( // If Authorization header is not valid error?.code === "token_not_valid" || @@ -49,6 +49,6 @@ function handleSessionExpired() { } } -function isBadRequest(error: QueryError) { +function isBadRequest(error: HTTPError) { return error.status === 400 || error.status === 406; } diff --git a/src/Utils/request/mutate.ts b/src/Utils/request/mutate.ts new file mode 100644 index 00000000000..2372920c162 --- /dev/null +++ b/src/Utils/request/mutate.ts @@ -0,0 +1,26 @@ +import { callApi } from "@/Utils/request/query"; +import { APICallOptions, Route } from "@/Utils/request/types"; + +/** + * Creates a TanStack Query compatible mutation function. + * + * Example: + * ```tsx + * const { mutate: createPrescription, isPending } = useMutation({ + * mutationFn: mutate(MedicineRoutes.createPrescription, { + * pathParams: { consultationId }, + * }), + * onSuccess: () => { + * toast.success(t("medication_request_prescribed")); + * }, + * }); + * ``` + */ +export default function mutate( + route: Route, + options?: APICallOptions, +) { + return (variables: TBody) => { + return callApi(route, { ...options, body: variables }); + }; +} diff --git a/src/Utils/request/query.ts b/src/Utils/request/query.ts index 53fe96878a2..dc79bd874ec 100644 --- a/src/Utils/request/query.ts +++ b/src/Utils/request/query.ts @@ -1,13 +1,12 @@ import careConfig from "@careConfig"; -import { QueryError } from "@/Utils/request/queryError"; import { getResponseBody } from "@/Utils/request/request"; -import { QueryOptions, Route } from "@/Utils/request/types"; +import { APICallOptions, HTTPError, Route } from "@/Utils/request/types"; import { makeHeaders, makeUrl } from "@/Utils/request/utils"; -async function queryRequest( +export async function callApi( { path, method, noAuth }: Route, - options?: QueryOptions, + options?: APICallOptions, ): Promise { const url = `${careConfig.apiUrl}${makeUrl(path, options?.queryParams, options?.pathParams)}`; @@ -32,7 +31,7 @@ async function queryRequest( const data = await getResponseBody(res); if (!res.ok) { - throw new QueryError({ + throw new HTTPError({ message: "Request Failed", status: res.status, silent: options?.silent ?? false, @@ -44,13 +43,27 @@ async function queryRequest( } /** - * Creates a TanStack Query compatible request function + * Creates a TanStack Query compatible query function. + * + * Example: + * ```tsx + * const { data, isLoading } = useQuery({ + * queryKey: ["prescription", consultationId], + * queryFn: query(MedicineRoutes.prescription, { + * pathParams: { consultationId }, + * queryParams: { + * limit: 10, + * offset: 0, + * }, + * }), + * }); + * ``` */ export default function query( route: Route, - options?: QueryOptions, + options?: APICallOptions, ) { return ({ signal }: { signal: AbortSignal }) => { - return queryRequest(route, { ...options, signal }); + return callApi(route, { ...options, signal }); }; } diff --git a/src/Utils/request/queryError.ts b/src/Utils/request/queryError.ts deleted file mode 100644 index cdfad312ef4..00000000000 --- a/src/Utils/request/queryError.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/types.ts b/src/Utils/request/types.ts index 2b4be31d28d..a53a28fb0b0 100644 --- a/src/Utils/request/types.ts +++ b/src/Utils/request/types.ts @@ -35,15 +35,46 @@ export interface RequestOptions { silent?: boolean; } -export interface QueryOptions { - pathParams?: Record; - queryParams?: Record; +export interface APICallOptions { + pathParams?: Record; + queryParams?: QueryParams; body?: TBody; silent?: boolean; signal?: AbortSignal; headers?: HeadersInit; } +type HTTPErrorCause = Record | undefined; + +export class HTTPError extends Error { + status: number; + silent: boolean; + cause?: HTTPErrorCause; + + 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; + } +} + +declare module "@tanstack/react-query" { + interface Register { + defaultError: HTTPError; + } +} + export interface PaginatedResponse { count: number; next: string | null; diff --git a/src/Utils/request/utils.ts b/src/Utils/request/utils.ts index 03427c17b44..26d69672f53 100644 --- a/src/Utils/request/utils.ts +++ b/src/Utils/request/utils.ts @@ -56,17 +56,24 @@ export function makeHeaders(noAuth: boolean, additionalHeaders?: HeadersInit) { headers.set("Content-Type", "application/json"); headers.append("Accept", "application/json"); - if (!noAuth) { - const token = localStorage.getItem(LocalStorageKeys.accessToken); - - if (token) { - headers.append("Authorization", `Bearer ${token}`); - } + const authorizationHeader = getAuthorizationHeader(); + if (authorizationHeader && !noAuth) { + headers.append("Authorization", authorizationHeader); } return headers; } +export function getAuthorizationHeader() { + const accessToken = localStorage.getItem(LocalStorageKeys.accessToken); + + if (accessToken) { + return `Bearer ${accessToken}`; + } + + return null; +} + export function mergeRequestOptions( options: RequestOptions, overrides: RequestOptions, diff --git a/src/components/Facility/FacilityHome.tsx b/src/components/Facility/FacilityHome.tsx index 1808a1087ee..7881a43123d 100644 --- a/src/components/Facility/FacilityHome.tsx +++ b/src/components/Facility/FacilityHome.tsx @@ -28,11 +28,7 @@ import { FieldLabel } from "@/components/Form/FormFields/FormField"; import useAuthUser from "@/hooks/useAuthUser"; import useSlug from "@/hooks/useSlug"; -import { - FACILITY_FEATURE_TYPES, - LocalStorageKeys, - USER_TYPES, -} from "@/common/constants"; +import { FACILITY_FEATURE_TYPES, USER_TYPES } from "@/common/constants"; import { PLUGIN_Component } from "@/PluginEngine"; import { NonReadOnlyUsers } from "@/Utils/AuthorizeFor"; @@ -42,6 +38,7 @@ import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import uploadFile from "@/Utils/request/uploadFile"; import useTanStackQueryInstead from "@/Utils/request/useQuery"; +import { getAuthorizationHeader } from "@/Utils/request/utils"; import { sleep } from "@/Utils/utils"; import { patientRegisterAuth } from "../Patient/PatientRegister"; @@ -121,10 +118,7 @@ export const FacilityHome = ({ facilityId }: Props) => { url, formData, "POST", - { - Authorization: - "Bearer " + localStorage.getItem(LocalStorageKeys.accessToken), - }, + { Authorization: getAuthorizationHeader() }, async (xhr: XMLHttpRequest) => { if (xhr.status === 200) { await sleep(1000); diff --git a/src/components/Users/UserAvatar.tsx b/src/components/Users/UserAvatar.tsx index db3620b34aa..77b353846bc 100644 --- a/src/components/Users/UserAvatar.tsx +++ b/src/components/Users/UserAvatar.tsx @@ -9,14 +9,13 @@ import Loading from "@/components/Common/Loading"; import useAuthUser from "@/hooks/useAuthUser"; -import { LocalStorageKeys } from "@/common/constants"; - import * as Notification from "@/Utils/Notifications"; import { showAvatarEdit } from "@/Utils/permissions"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import uploadFile from "@/Utils/request/uploadFile"; import useTanStackQueryInstead from "@/Utils/request/useQuery"; +import { getAuthorizationHeader } from "@/Utils/request/utils"; import { formatDisplayName, sleep } from "@/Utils/utils"; export default function UserAvatar({ username }: { username: string }) { @@ -47,10 +46,7 @@ export default function UserAvatar({ username }: { username: string }) { url, formData, "POST", - { - Authorization: - "Bearer " + localStorage.getItem(LocalStorageKeys.accessToken), - }, + { Authorization: getAuthorizationHeader() }, async (xhr: XMLHttpRequest) => { if (xhr.status === 200) { await sleep(1000); diff --git a/src/components/Users/UserProfile.tsx b/src/components/Users/UserProfile.tsx index 4dd98635510..786e569c4db 100644 --- a/src/components/Users/UserProfile.tsx +++ b/src/components/Users/UserProfile.tsx @@ -26,7 +26,7 @@ import { import useAuthUser, { useAuthContext } from "@/hooks/useAuthUser"; -import { GENDER_TYPES, LocalStorageKeys } from "@/common/constants"; +import { GENDER_TYPES } from "@/common/constants"; import { validateEmailAddress } from "@/common/validation"; import * as Notification from "@/Utils/Notifications"; @@ -35,6 +35,7 @@ import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import uploadFile from "@/Utils/request/uploadFile"; import useTanStackQueryInstead from "@/Utils/request/useQuery"; +import { getAuthorizationHeader } from "@/Utils/request/utils"; import { dateQueryString, formatDate, @@ -507,10 +508,7 @@ export default function UserProfile() { url, formData, "POST", - { - Authorization: - "Bearer " + localStorage.getItem(LocalStorageKeys.accessToken), - }, + { Authorization: getAuthorizationHeader() }, async (xhr: XMLHttpRequest) => { if (xhr.status === 200) { await sleep(1000);