Skip to content

Commit

Permalink
chore(base): setup react query MAASENG-3401 (#5484)
Browse files Browse the repository at this point in the history
refactor(base): setup react query MAASENG-3401
- react Query integration
- add websocket-aware query
- add zones-related hooks
- integrate React Query DevTools
  • Loading branch information
petermakowski authored Jun 25, 2024
1 parent 7e3cb5d commit 3040e2b
Show file tree
Hide file tree
Showing 14 changed files with 371 additions and 58 deletions.
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
VITE_APP_CHECK_MIDDLEWARE=true
VITE_APP_WEBSOCKET_DEBUG=true
VITE_APP_REACT_QUERY_DEVTOOLS=false
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@
"@storybook/react": "7.6.19",
"@storybook/react-webpack5": "7.6.19",
"@storybook/theming": "7.6.19",
"@tanstack/react-query": "5.45.1",
"@tanstack/react-query-devtools": "5.45.1",
"@testing-library/cypress": "10.0.1",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.4.5",
Expand Down
44 changes: 44 additions & 0 deletions src/app/api/base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { it, expect, vi, type Mock } from "vitest";

import { DEFAULT_HEADERS, fetchWithAuth } from "./base";

import { getCookie } from "@/app/utils";

vi.mock("@/app/utils", () => ({ getCookie: vi.fn() }));

const mockCsrfToken = "mock-csrf-token";
const url = "https://example.com/api";

const originalFetch = global.fetch;

beforeEach(() => {
(getCookie as Mock).mockReturnValue(mockCsrfToken);
});

afterAll(() => {
global.fetch = originalFetch;
});

it("should call fetch with correct parameters", async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ data: "test" }),
};
global.fetch = vi.fn().mockResolvedValue(mockResponse);

const options = { method: "POST", body: JSON.stringify({ test: true }) };
const result = await fetchWithAuth(url, options);

expect(fetch).toHaveBeenCalledWith(url, {
...options,
headers: { ...DEFAULT_HEADERS, "X-CSRFToken": mockCsrfToken },
});
expect(result).toEqual({ data: "test" });
});

it("should handle errors", async () => {
global.fetch = vi
.fn()
.mockResolvedValue({ ok: false, statusText: "Bad Request" });
await expect(fetchWithAuth(url)).rejects.toThrow("Bad Request");
});
27 changes: 27 additions & 0 deletions src/app/api/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getCookie } from "@/app/utils";

export const ROOT_API = "/MAAS/api/2.0/";

export const DEFAULT_HEADERS = {
"Content-Type": "application/json",
Accept: "application/json",
};

export const handleErrors = (response: Response) => {
if (!response.ok) {
throw Error(response.statusText);
}
return response;
};

export const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
const csrftoken = getCookie("csrftoken");
const headers = {
...DEFAULT_HEADERS,
"X-CSRFToken": csrftoken || "",
...options.headers,
};

const response = await fetch(url, { ...options, headers });
return handleErrors(response).json();
};
5 changes: 5 additions & 0 deletions src/app/api/endpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ROOT_API, fetchWithAuth } from "@/app/api/base";
import type { Zone } from "@/app/store/zone/types";

export const fetchZones = (): Promise<Zone[]> =>
fetchWithAuth(`${ROOT_API}zones/`);
32 changes: 32 additions & 0 deletions src/app/api/query-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { QueryClient } from "@tanstack/react-query";

export const queryKeys = {
zones: {
list: ["zones"],
},
} as const;

type QueryKeys = typeof queryKeys;
type QueryKeyCategories = keyof QueryKeys;
type QueryKeySubcategories<T extends QueryKeyCategories> = keyof QueryKeys[T];

export type QueryKey =
QueryKeys[QueryKeyCategories][QueryKeySubcategories<QueryKeyCategories>];

export const defaultQueryOptions = {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 15 * 60 * 1000, // 15 minutes
refetchOnWindowFocus: true,
} as const;

export const realTimeQueryOptions = {
staleTime: 0,
cacheTime: 60 * 1000, // 1 minute
} as const;

export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: defaultQueryOptions,
},
});
65 changes: 65 additions & 0 deletions src/app/api/query/base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as reactQuery from "@tanstack/react-query";

import { useWebsocketAwareQuery } from "./base";

import { rootState, statusState } from "@/testing/factories";
import { renderHookWithMockStore } from "@/testing/utils";

vi.mock("@tanstack/react-query");

const mockQueryFn = vi.fn();
const mockQueryKey = ["zones"] as const;

beforeEach(() => {
vi.resetAllMocks();
const mockQueryClient: Partial<reactQuery.QueryClient> = {
invalidateQueries: vi.fn(),
};
vi.mocked(reactQuery.useQueryClient).mockReturnValue(
mockQueryClient as reactQuery.QueryClient
);
vi.mocked(reactQuery.useQuery).mockReturnValue({
data: "testData",
isLoading: false,
} as reactQuery.UseQueryResult);
});

it("calls useQuery with correct parameters", () => {
renderHookWithMockStore(() =>
useWebsocketAwareQuery(mockQueryKey, mockQueryFn)
);
expect(reactQuery.useQuery).toHaveBeenCalledWith({
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
});

it("invalidates queries when connectedCount changes", () => {
const initialState = rootState({
status: statusState({ connectedCount: 0 }),
});
const { rerender } = renderHookWithMockStore(
() => useWebsocketAwareQuery(mockQueryKey, mockQueryFn),
{ initialState }
);

const mockInvalidateQueries = vi.fn();
const mockQueryClient: Partial<reactQuery.QueryClient> = {
invalidateQueries: mockInvalidateQueries,
};
vi.mocked(reactQuery.useQueryClient).mockReturnValue(
mockQueryClient as reactQuery.QueryClient
);

rerender({
initialState: rootState({ status: statusState({ connectedCount: 1 }) }),
});
expect(mockInvalidateQueries).toHaveBeenCalled();
});

it("returns the result of useQuery", () => {
const { result } = renderHookWithMockStore(() =>
useWebsocketAwareQuery(mockQueryKey, mockQueryFn)
);
expect(result.current).toEqual({ data: "testData", isLoading: false });
});
34 changes: 34 additions & 0 deletions src/app/api/query/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from "react";

import type { QueryFunction, UseQueryOptions } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSelector } from "react-redux";

import type { QueryKey } from "@/app/api/query-client";
import statusSelectors from "@/app/store/status/selectors";

export function useWebsocketAwareQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
>(
queryKey: QueryKey,
queryFn: QueryFunction<TQueryFnData>,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData>,
"queryKey" | "queryFn"
>
) {
const queryClient = useQueryClient();
const connectedCount = useSelector(statusSelectors.connectedCount);

useEffect(() => {
queryClient.invalidateQueries();
}, [connectedCount, queryClient, queryKey]);

return useQuery<TQueryFnData, TError, TData>({
queryKey,
queryFn,
...options,
});
}
46 changes: 46 additions & 0 deletions src/app/api/query/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { UseQueryResult } from "@tanstack/react-query";

import { useItemsCount } from "./utils";

import { renderHook } from "@/testing/utils";

it("should return 0 when data is undefined", () => {
const mockUseItems = vi.fn(
() => ({ data: undefined }) as UseQueryResult<any[], unknown>
);
const { result } = renderHook(() => useItemsCount(mockUseItems));
expect(result.current).toBe(0);
});

it("should return the correct count when data is available", () => {
const mockData = [1, 2, 3, 4, 5];
const mockUseItems = vi.fn(
() => ({ data: mockData }) as UseQueryResult<number[], unknown>
);
const { result } = renderHook(() => useItemsCount(mockUseItems));
expect(result.current).toBe(5);
});

it("should return 0 when data is an empty array", () => {
const mockUseItems = vi.fn();
mockUseItems.mockReturnValueOnce({ data: [] } as UseQueryResult<[], unknown>);
const { result } = renderHook(() => useItemsCount(mockUseItems));
expect(result.current).toBe(0);
});

it("should update count when data changes", () => {
const mockUseItems = vi.fn();
mockUseItems.mockReturnValueOnce({ data: [1, 2, 3] } as UseQueryResult<
number[],
unknown
>);
const { result, rerender } = renderHook(() => useItemsCount(mockUseItems));
expect(result.current).toBe(3);

mockUseItems.mockReturnValueOnce({ data: [1, 2, 3, 4] } as UseQueryResult<
number[],
unknown
>);
rerender();
expect(result.current).toBe(4);
});
10 changes: 10 additions & 0 deletions src/app/api/query/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useMemo } from "react";

import type { UseQueryResult } from "@tanstack/react-query";

type QueryHook<T> = () => UseQueryResult<T[], unknown>;

export const useItemsCount = <T>(useItems: QueryHook<T>) => {
const { data } = useItems();
return useMemo(() => data?.length ?? 0, [data]);
};
9 changes: 9 additions & 0 deletions src/app/api/query/zones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { fetchZones } from "@/app/api/endpoints";
import { useWebsocketAwareQuery } from "@/app/api/query/base";
import { useItemsCount } from "@/app/api/query/utils";

export const useZones = () => {
return useWebsocketAwareQuery(["zones"], fetchZones);
};

export const useZonesCount = () => useItemsCount(useZones);
28 changes: 20 additions & 8 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
import { StrictMode } from "react";

import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { HistoryRouter as Router } from "redux-first-history/rr6";

import packageInfo from "../package.json";

import App from "./app/App";
import { createQueryClient } from "./app/api/query-client";
import SidePanelContextProvider from "./app/base/side-panel-context";
import { store, history } from "./redux-store";
import * as serviceWorker from "./serviceWorker";

import "./scss/index.scss";

const queryClient = createQueryClient();

export const RootProviders = ({ children }: { children: JSX.Element }) => {
return (
<Provider store={store}>
<Router
basename={`${import.meta.env.VITE_APP_BASENAME}${
import.meta.env.VITE_APP_VITE_BASENAME
}`}
history={history}
>
<SidePanelContextProvider>{children}</SidePanelContextProvider>
</Router>
<QueryClientProvider client={queryClient}>
<Router
basename={`${import.meta.env.VITE_APP_BASENAME}${
import.meta.env.VITE_APP_VITE_BASENAME
}`}
history={history}
>
<SidePanelContextProvider>{children}</SidePanelContextProvider>
</Router>
<ReactQueryDevtools
initialIsOpen={
import.meta.env.VITE_APP_REACT_QUERY_DEVTOOLS === "true"
}
/>
</QueryClientProvider>
</Provider>
);
};
Expand Down
Loading

0 comments on commit 3040e2b

Please sign in to comment.