Skip to content

Commit

Permalink
Adds useMutation compatible utility function and other abstractions (
Browse files Browse the repository at this point in the history
  • Loading branch information
rithviknishad authored Dec 17, 2024
1 parent 69527b8 commit ba80ea9
Show file tree
Hide file tree
Showing 12 changed files with 194 additions and 71 deletions.
2 changes: 1 addition & 1 deletion .cursorrules
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 6 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
MutationCache,
QueryCache,
QueryClient,
QueryClientProvider,
Expand All @@ -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";

Expand All @@ -29,7 +30,10 @@ const queryClient = new QueryClient({
},
},
queryCache: new QueryCache({
onError: handleQueryError,
onError: handleHttpError,
}),
mutationCache: new MutationCache({
onError: handleHttpError,
}),
});

Expand Down
82 changes: 80 additions & 2 deletions src/Utils/request/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ function FacilityDetails({ id }: { id: string }) {
- Integrates with our global error handling.

```typescript
interface QueryOptions {
interface APICallOptions {
pathParams?: Record<string, string>; // URL parameters
queryParams?: Record<string, string>; // Query string parameters
queryParams?: QueryParams; // Query string parameters
body?: TBody; // Request body
silent?: boolean; // Suppress error notifications
headers?: HeadersInit; // Additional headers
}
// Basic usage
Expand Down Expand Up @@ -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 (
<Button
onClick={() => createPrescription({ medicineId: "123", dosage: "1x daily" })}
disabled={isPending}
>
Create Prescription
</Button>
);
}
// 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 <PatientForm onSubmit={handleSubmit} />;
}
```

### 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<string, string>; // 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
Expand Down
10 changes: 5 additions & 5 deletions src/Utils/request/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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" ||
Expand All @@ -49,6 +49,6 @@ function handleSessionExpired() {
}
}

function isBadRequest(error: QueryError) {
function isBadRequest(error: HTTPError) {
return error.status === 400 || error.status === 406;
}
26 changes: 26 additions & 0 deletions src/Utils/request/mutate.ts
Original file line number Diff line number Diff line change
@@ -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<TData, TBody>(
route: Route<TData, TBody>,
options?: APICallOptions<TBody>,
) {
return (variables: TBody) => {
return callApi(route, { ...options, body: variables });
};
}
29 changes: 21 additions & 8 deletions src/Utils/request/query.ts
Original file line number Diff line number Diff line change
@@ -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<TData, TBody>(
export async function callApi<TData, TBody>(
{ path, method, noAuth }: Route<TData, TBody>,
options?: QueryOptions<TBody>,
options?: APICallOptions<TBody>,
): Promise<TData> {
const url = `${careConfig.apiUrl}${makeUrl(path, options?.queryParams, options?.pathParams)}`;

Expand All @@ -32,7 +31,7 @@ async function queryRequest<TData, TBody>(
const data = await getResponseBody<TData>(res);

if (!res.ok) {
throw new QueryError({
throw new HTTPError({
message: "Request Failed",
status: res.status,
silent: options?.silent ?? false,
Expand All @@ -44,13 +43,27 @@ async function queryRequest<TData, TBody>(
}

/**
* 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<TData, TBody>(
route: Route<TData, TBody>,
options?: QueryOptions<TBody>,
options?: APICallOptions<TBody>,
) {
return ({ signal }: { signal: AbortSignal }) => {
return queryRequest(route, { ...options, signal });
return callApi(route, { ...options, signal });
};
}
24 changes: 0 additions & 24 deletions src/Utils/request/queryError.ts

This file was deleted.

37 changes: 34 additions & 3 deletions src/Utils/request/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,46 @@ export interface RequestOptions<TData = unknown, TBody = unknown> {
silent?: boolean;
}

export interface QueryOptions<TBody = unknown> {
pathParams?: Record<string, string>;
queryParams?: Record<string, string>;
export interface APICallOptions<TBody = unknown> {
pathParams?: Record<string, string | number>;
queryParams?: QueryParams;
body?: TBody;
silent?: boolean;
signal?: AbortSignal;
headers?: HeadersInit;
}

type HTTPErrorCause = Record<string, unknown> | 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<string, unknown>;
}) {
super(message, { cause });
this.status = status;
this.silent = silent;
this.cause = cause;
}
}

declare module "@tanstack/react-query" {
interface Register {
defaultError: HTTPError;
}
}

export interface PaginatedResponse<TItem> {
count: number;
next: string | null;
Expand Down
19 changes: 13 additions & 6 deletions src/Utils/request/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TData>(
options: RequestOptions<TData>,
overrides: RequestOptions<TData>,
Expand Down
12 changes: 3 additions & 9 deletions src/components/Facility/FacilityHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit ba80ea9

Please sign in to comment.