diff --git a/.env.development b/.env.development index 9feafb8017..38c6cd5bae 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,3 @@ VITE_APP_CHECK_MIDDLEWARE=true VITE_APP_WEBSOCKET_DEBUG=true +VITE_APP_REACT_QUERY_DEVTOOLS=false diff --git a/package.json b/package.json index bed2b330e8..228a2be0fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/base.test.ts b/src/app/api/base.test.ts new file mode 100644 index 0000000000..d665931764 --- /dev/null +++ b/src/app/api/base.test.ts @@ -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"); +}); diff --git a/src/app/api/base.ts b/src/app/api/base.ts new file mode 100644 index 0000000000..1e85d44c03 --- /dev/null +++ b/src/app/api/base.ts @@ -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(); +}; diff --git a/src/app/api/endpoints.ts b/src/app/api/endpoints.ts new file mode 100644 index 0000000000..23216ce10e --- /dev/null +++ b/src/app/api/endpoints.ts @@ -0,0 +1,5 @@ +import { ROOT_API, fetchWithAuth } from "@/app/api/base"; +import type { Zone } from "@/app/store/zone/types"; + +export const fetchZones = (): Promise => + fetchWithAuth(`${ROOT_API}zones/`); diff --git a/src/app/api/query-client.ts b/src/app/api/query-client.ts new file mode 100644 index 0000000000..f7b5f75b25 --- /dev/null +++ b/src/app/api/query-client.ts @@ -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 = keyof QueryKeys[T]; + +export type QueryKey = + QueryKeys[QueryKeyCategories][QueryKeySubcategories]; + +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, + }, + }); diff --git a/src/app/api/query/base.test.ts b/src/app/api/query/base.test.ts new file mode 100644 index 0000000000..95db9feb6f --- /dev/null +++ b/src/app/api/query/base.test.ts @@ -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 = { + 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 = { + 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 }); +}); diff --git a/src/app/api/query/base.ts b/src/app/api/query/base.ts new file mode 100644 index 0000000000..9e925f227c --- /dev/null +++ b/src/app/api/query/base.ts @@ -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, + options?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > +) { + const queryClient = useQueryClient(); + const connectedCount = useSelector(statusSelectors.connectedCount); + + useEffect(() => { + queryClient.invalidateQueries(); + }, [connectedCount, queryClient, queryKey]); + + return useQuery({ + queryKey, + queryFn, + ...options, + }); +} diff --git a/src/app/api/query/utils.test.ts b/src/app/api/query/utils.test.ts new file mode 100644 index 0000000000..c42335c240 --- /dev/null +++ b/src/app/api/query/utils.test.ts @@ -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 + ); + 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 + ); + 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); +}); diff --git a/src/app/api/query/utils.ts b/src/app/api/query/utils.ts new file mode 100644 index 0000000000..c3a72ecf14 --- /dev/null +++ b/src/app/api/query/utils.ts @@ -0,0 +1,10 @@ +import { useMemo } from "react"; + +import type { UseQueryResult } from "@tanstack/react-query"; + +type QueryHook = () => UseQueryResult; + +export const useItemsCount = (useItems: QueryHook) => { + const { data } = useItems(); + return useMemo(() => data?.length ?? 0, [data]); +}; diff --git a/src/app/api/query/zones.ts b/src/app/api/query/zones.ts new file mode 100644 index 0000000000..36d8669ee3 --- /dev/null +++ b/src/app/api/query/zones.ts @@ -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); diff --git a/src/index.tsx b/src/index.tsx index 2bf1cc55cd..b0ae66c945 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,7 @@ 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"; @@ -7,23 +9,33 @@ 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 ( - - {children} - + + + {children} + + + ); }; diff --git a/src/testing/utils.tsx b/src/testing/utils.tsx index 3f9fc75027..5427d10842 100644 --- a/src/testing/utils.tsx +++ b/src/testing/utils.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import type { ValueOf } from "@canonical/react-components"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { RenderOptions, RenderResult } from "@testing-library/react"; import { render, screen, renderHook } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -40,6 +41,14 @@ import { zoneState as zoneStateFactory, } from "@/testing/factories"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + /** * Replace objects in an array with objects that have new values, given a match * criteria. @@ -119,22 +128,28 @@ export const BrowserRouterWithProvider = ({ const route = ; return ( - - - - {routePattern ? ( - - {parentRoute ? {route} : route} - - ) : ( - children - )} - - - + + + + + {routePattern ? ( + + {parentRoute ? ( + {route} + ) : ( + route + )} + + ) : ( + children + )} + + + + ); }; @@ -148,9 +163,11 @@ const WithMockStoreProvider = ({ return mockStore(state); }; return ( - - {children} - + + + {children} + + ); }; @@ -195,7 +212,9 @@ export const renderWithMockStore = ( const rendered = render(ui, { wrapper: (props) => ( - + + + ), ...renderOptions, }); @@ -322,8 +341,13 @@ const generateWrapper = ); type Hook = Parameters[0]; -export const renderHookWithMockStore = (hook: Hook) => { - return renderHook(hook, { wrapper: generateWrapper() }); + +export const renderHookWithMockStore = ( + hook: Hook, + options?: { initialState?: RootState } +) => { + const store = configureStore()(options?.initialState || rootStateFactory()); + return renderHook(hook, { wrapper: generateWrapper(store) }); }; export const waitFor = vi.waitFor; diff --git a/yarn.lock b/yarn.lock index e5b2208877..8a47a3f5cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4427,6 +4427,30 @@ resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a" integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw== +"@tanstack/query-core@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.45.0.tgz#47a662d311c2588867341238960ec21dc7f0714e" + integrity sha512-RVfIZQmFUTdjhSAAblvueimfngYyfN6HlwaJUPK71PKd7yi43Vs1S/rdimmZedPWX/WGppcq/U1HOj7O7FwYxw== + +"@tanstack/query-devtools@5.37.1": + version "5.37.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.37.1.tgz#8dcfa1488b4f2e353be7eede6691b0ad9197183b" + integrity sha512-XcG4IIHIv0YQKrexTqo2zogQWR1Sz672tX2KsfE9kzB+9zhx44vRKH5si4WDILE1PIWQpStFs/NnrDQrBAUQpg== + +"@tanstack/react-query-devtools@5.45.1": + version "5.45.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.45.1.tgz#bea7ba0ffd509f0930237c2df7feba9209f76aa6" + integrity sha512-4mrbk1g5jqlqh0pifZNsKzy7FtgeqgwzMICL4d6IJGayrrcrKq9K4N/OzRNbgRWrTn6YTY63qcAcKo+NJU2QMw== + dependencies: + "@tanstack/query-devtools" "5.37.1" + +"@tanstack/react-query@5.45.1": + version "5.45.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.45.1.tgz#a0ac6bb89b4a2c2b0251f6647a0a370d86f05347" + integrity sha512-mYYfJujKg2kxmkRRjA6nn4YKG3ITsKuH22f1kteJ5IuVQqgKUgbaSQfYwVP0gBS05mhwxO03HVpD0t7BMN7WOA== + dependencies: + "@tanstack/query-core" "5.45.0" + "@testing-library/cypress@10.0.1": version "10.0.1" resolved "https://registry.yarnpkg.com/@testing-library/cypress/-/cypress-10.0.1.tgz#15abae0edb83237316ec6d07e152b71a50b38387" @@ -12753,7 +12777,8 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12771,15 +12796,6 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -12845,7 +12861,8 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12859,13 +12876,6 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" @@ -14111,7 +14121,8 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14129,15 +14140,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"