From 9215ed6ad49a46a3187b9656b82def3276dc898a Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Wed, 3 Jul 2024 15:32:57 +0200 Subject: [PATCH] feat(zones): fetch zones using react-query MAASENG-3404 (#5485) - Enable React Query for managing zone-related data fetching and caching - Add `WebSocketEndpoints` detailing allowed WebSocket endpoint models and methods - Remove unused imports and code related to fetching zones - Refactor code to use new `useZoneById` hook for fetching zones by ID - Update testing utilities to support React Query and WebSocket testing - Add new helper functions in testing/utils for setting up initial state and query data - Modify `renderWithBrowserRouter` to return `store` and `queryClient` for more concise tests --- src/app/Routes.test.tsx | 14 +- src/app/api/base.ts | 2 +- src/app/api/query-client.ts | 3 + src/app/api/query/base.test.ts | 1 + src/app/api/query/base.ts | 50 +++- src/app/api/query/utils.test.ts | 80 ++--- src/app/api/query/utils.ts | 26 +- src/app/api/query/zones.test.ts | 72 +++-- src/app/api/query/zones.ts | 14 +- .../NodeConfigurationFields.test.tsx | 48 ++- .../components/ZoneSelect/ZoneSelect.test.tsx | 40 +-- .../base/components/ZoneSelect/ZoneSelect.tsx | 16 +- .../node/SetZoneForm/SetZoneForm.test.tsx | 96 +++--- src/app/base/sagas/http.ts | 36 --- src/app/base/sagas/index.ts | 2 - .../base/sagas/websockets/websockets.test.ts | 161 +++++----- src/app/base/sagas/websockets/websockets.ts | 32 +- src/app/base/websocket-context.tsx | 16 + .../ControllerConfiguration.test.tsx | 113 +++---- .../AddDeviceForm/AddDeviceForm.test.tsx | 23 +- .../AddDeviceForm/AddDeviceForm.tsx | 16 +- .../DeviceHeaderForms.test.tsx | 10 +- .../DeviceConfiguration.test.tsx | 64 ++-- .../DeviceConfiguration.tsx | 13 +- .../DeviceDetails/DeviceDetails.test.tsx | 21 +- .../domains/views/DomainsList/DomainsList.tsx | 8 +- .../KVMConfigurationCard.test.tsx | 73 ++--- .../KVMConfigurationCardFields.test.tsx | 7 +- .../KVMForms/AddLxd/AddLxd.test.tsx | 12 +- .../kvm/components/KVMForms/AddLxd/AddLxd.tsx | 6 +- .../AuthenticationForm.test.tsx | 16 +- .../CredentialsForm/CredentialsForm.test.tsx | 22 +- .../SelectProjectForm.test.tsx | 25 +- .../KVMForms/AddVirsh/AddVirsh.test.tsx | 54 ++-- .../components/KVMForms/AddVirsh/AddVirsh.tsx | 16 +- .../AddVirshFields/AddVirshFields.test.tsx | 4 +- .../KVMForms/ComposeForm/ComposeForm.test.tsx | 12 +- .../KVMForms/ComposeForm/ComposeForm.tsx | 12 +- .../ComposeFormFields.test.tsx | 3 +- .../kvm/components/KVMForms/KVMForms.test.tsx | 27 +- .../components/PoolColumn/PoolColumn.test.tsx | 43 ++- .../kvm/components/PoolColumn/PoolColumn.tsx | 6 +- src/app/kvm/views/KVMList/KVMList.test.tsx | 1 - src/app/kvm/views/KVMList/KVMList.tsx | 2 - .../LxdKVMHostTable/LxdKVMHostTable.tsx | 7 +- .../KVMList/VirshTable/VirshTable.test.tsx | 43 ++- .../LXDClusterDetailsHeader.test.tsx | 140 ++------- .../LXDClusterDetailsHeader.tsx | 12 +- .../LXDSingleDetailsHeader.test.tsx | 78 ++--- .../LXDSingleDetailsHeader.tsx | 11 +- .../LXDSingleSettings.test.tsx | 28 +- .../LXDSingleSettings/LXDSingleSettings.tsx | 13 +- .../VirshDetailsHeader.test.tsx | 9 +- .../VirshDetailsHeader/VirshDetailsHeader.tsx | 11 +- .../VirshSettings/VirshSettings.test.tsx | 29 +- .../VirshSettings/VirshSettings.tsx | 13 +- .../AddMachineForm/AddMachineForm.test.tsx | 125 ++++---- .../AddMachineForm/AddMachineForm.tsx | 11 +- .../AddMachineFormFields.test.tsx | 1 - .../MachineForm/MachineForm.test.tsx | 68 ++--- .../MachineListTable.test.tsx | 27 +- .../MachineListTable/MachineListTable.tsx | 2 - .../ZoneColumn/ZoneColumn.test.tsx | 54 ++-- .../ZoneColumn/ZoneColumn.tsx | 12 +- .../DiscoveryAddForm.test.tsx | 4 +- src/app/store/domain/slice.ts | 12 +- src/app/store/general/slice.ts | 4 +- src/app/store/machine/slice.ts | 51 ++-- src/app/store/utils/slice.ts | 19 +- src/app/store/zone/actions.test.ts | 7 - src/app/store/zone/reducers.test.ts | 101 ------- src/app/store/zone/selectors.test.ts | 60 ---- src/app/store/zone/selectors.ts | 27 -- src/app/store/zone/slice.ts | 41 --- src/app/store/zone/types/base.ts | 1 - .../TagsHeader/AddTagForm/AddTagForm.test.tsx | 79 ++++- .../views/TagMachines/TagMachines.test.tsx | 82 ++---- .../views/ZoneDetails/ZoneDetails.test.tsx | 68 ++--- .../zones/views/ZoneDetails/ZoneDetails.tsx | 16 +- .../ZoneDetailsContent/ZoneDetailsContent.tsx | 20 +- .../ZoneDetailsForm/ZoneDetailsForm.test.tsx | 44 +-- .../ZoneDetailsForm/ZoneDetailsForm.tsx | 8 +- .../DeleteConfirm/DeleteConfirm.test.tsx | 64 ++-- .../ZoneDetailsHeader.test.tsx | 155 ++++------ .../ZoneDetailsHeader/ZoneDetailsHeader.tsx | 28 +- .../zones/views/ZonesList/ZonesList.test.tsx | 42 +-- src/app/zones/views/ZonesList/ZonesList.tsx | 13 +- .../ZonesListHeader/ZonesListHeader.tsx | 14 +- .../ZonesListTable/ZonesListTable.tsx | 12 +- src/index.tsx | 33 ++- src/redux-store.ts | 3 +- src/root-saga.ts | 2 - src/testing/factories/index.ts | 1 + src/testing/factories/response.ts | 5 + src/testing/factories/state.ts | 1 - src/testing/utils.tsx | 275 ++++++++++++++---- src/websocket-client.test.ts | 4 +- src/websocket-client.ts | 199 ++++++++++++- 98 files changed, 1654 insertions(+), 1853 deletions(-) create mode 100644 src/app/base/websocket-context.tsx create mode 100644 src/testing/factories/response.ts diff --git a/src/app/Routes.test.tsx b/src/app/Routes.test.tsx index 7b72582525..b55686996c 100644 --- a/src/app/Routes.test.tsx +++ b/src/app/Routes.test.tsx @@ -109,7 +109,9 @@ const routes: { title: string; path: string }[] = [ describe("Routes", () => { let state: RootState; let scrollToSpy: Mock; - + const queryData = { + zones: [factory.zone({ id: 1, name: "test-zone" })], + }; beforeEach(() => { state = factory.rootState({ user: factory.userState({ @@ -146,14 +148,7 @@ describe("Routes", () => { }), ], }), - zone: factory.zoneState({ - items: [ - factory.zone({ - id: 1, - name: "test-zone", - }), - ], - }), + zone: factory.zoneState({}), }); scrollToSpy = vi.fn(); global.scrollTo = scrollToSpy; @@ -168,6 +163,7 @@ describe("Routes", () => { renderWithBrowserRouter(, { route: path, state, + queryData, routePattern: "/*", }); await waitFor(() => expect(document.title).toBe(`${title} | MAAS`), { diff --git a/src/app/api/base.ts b/src/app/api/base.ts index 9e7f8e25e7..f125c42d20 100644 --- a/src/app/api/base.ts +++ b/src/app/api/base.ts @@ -18,7 +18,7 @@ export const handleErrors = (response: Response) => { }; type ApiEndpoint = typeof API_ENDPOINTS; -type ApiEndpointKey = keyof ApiEndpoint; +export type ApiEndpointKey = keyof ApiEndpoint; type ApiUrl = `${typeof SERVICE_API}${ApiEndpoint[ApiEndpointKey]}`; export const getFullApiUrl = (endpoint: ApiEndpointKey): ApiUrl => diff --git a/src/app/api/query-client.ts b/src/app/api/query-client.ts index f7b5f75b25..823a9e72dc 100644 --- a/src/app/api/query-client.ts +++ b/src/app/api/query-client.ts @@ -13,6 +13,9 @@ type QueryKeySubcategories = keyof QueryKeys[T]; export type QueryKey = QueryKeys[QueryKeyCategories][QueryKeySubcategories]; +// first element of the queryKeys array +export type QueryModel = QueryKey[number]; + export const defaultQueryOptions = { staleTime: 5 * 60 * 1000, // 5 minutes cacheTime: 15 * 60 * 1000, // 15 minutes diff --git a/src/app/api/query/base.test.ts b/src/app/api/query/base.test.ts index 95db9feb6f..6fd2d38f20 100644 --- a/src/app/api/query/base.test.ts +++ b/src/app/api/query/base.test.ts @@ -61,5 +61,6 @@ it("returns the result of useQuery", () => { const { result } = renderHookWithMockStore(() => useWebsocketAwareQuery(mockQueryKey, mockQueryFn) ); + expect(result.current).not.toBeNull(); expect(result.current).toEqual({ data: "testData", isLoading: false }); }); diff --git a/src/app/api/query/base.ts b/src/app/api/query/base.ts index 9e925f227c..8e54b4eab8 100644 --- a/src/app/api/query/base.ts +++ b/src/app/api/query/base.ts @@ -1,12 +1,44 @@ -import { useEffect } from "react"; +import { useEffect, useCallback, useContext } 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 { WebSocketContext } from "@/app/base/websocket-context"; import statusSelectors from "@/app/store/status/selectors"; +import type { WebSocketEndpointModel } from "@/websocket-client"; +import { WebSocketMessageType } from "@/websocket-client"; +export const useWebSocket = () => { + const websocketClient = useContext(WebSocketContext); + + if (!websocketClient) { + throw new Error("useWebSocket must be used within a WebSocketProvider"); + } + + const subscribe = useCallback( + (callback: (msg: any) => void) => { + if (!websocketClient.rws) return; + + const messageHandler = (messageEvent: MessageEvent) => { + const data = JSON.parse(messageEvent.data); + if (data.type === WebSocketMessageType.NOTIFY) callback(data); + }; + websocketClient.rws.addEventListener("message", messageHandler); + return () => + websocketClient.rws?.removeEventListener("message", messageHandler); + }, + [websocketClient] + ); + + return { subscribe }; +}; + +const wsToQueryKeyMapping: Partial> = { + zone: "zones", + // Add more mappings as needed +} as const; export function useWebsocketAwareQuery< TQueryFnData = unknown, TError = unknown, @@ -21,11 +53,27 @@ export function useWebsocketAwareQuery< ) { const queryClient = useQueryClient(); const connectedCount = useSelector(statusSelectors.connectedCount); + const { subscribe } = useWebSocket(); + + const queryModelKey = Array.isArray(queryKey) ? queryKey[0] : ""; useEffect(() => { queryClient.invalidateQueries(); }, [connectedCount, queryClient, queryKey]); + useEffect(() => { + return subscribe( + ({ name: model }: { action: string; name: WebSocketEndpointModel }) => { + const mappedKey = wsToQueryKeyMapping[model]; + const modelQueryKey = queryKey[0]; + + if (mappedKey && mappedKey === modelQueryKey) { + queryClient.invalidateQueries({ queryKey }); + } + } + ); + }, [queryClient, subscribe, queryModelKey, queryKey]); + return useQuery({ queryKey, queryFn, diff --git a/src/app/api/query/utils.test.ts b/src/app/api/query/utils.test.ts index c42335c240..d95a0af8f8 100644 --- a/src/app/api/query/utils.test.ts +++ b/src/app/api/query/utils.test.ts @@ -1,46 +1,50 @@ -import type { UseQueryResult } from "@tanstack/react-query"; +import { selectItemsCount, selectById } from "./utils"; -import { useItemsCount } from "./utils"; +describe("selectItemsCount", () => { + it("should return 0 for undefined input", () => { + const count = selectItemsCount()(undefined); + expect(count).toBe(0); + }); -import { renderHook } from "@/testing/utils"; + it("should return the correct count for a non-empty array", () => { + const data = [1, 2, 3, 4, 5]; + const count = selectItemsCount()(data); + expect(count).toBe(5); + }); -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 0 for an empty array", () => { + const data: number[] = []; + const count = selectItemsCount()(data); + expect(count).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); -}); +describe("selectById", () => { + const testData = [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + { id: 3, name: "Item 3" }, + { id: null, name: "Null ID Item" }, + ]; -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 return the correct item when given a valid ID", () => { + const item = selectById(2)(testData); + expect(item).toEqual({ id: 2, name: "Item 2" }); + }); + + it("should return null when given an ID that does not exist", () => { + const item = selectById(4)(testData); + expect(item).toBeNull(); + }); + + it("should return the correct item when given a null ID", () => { + const item = selectById(null)(testData); + expect(item).toEqual({ id: null, name: "Null ID Item" }); + }); -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); + it("should return null when given a null ID and no matching item exists", () => { + const dataWithoutNullId = testData.filter((item) => item.id !== null); + const item = selectById(null)(dataWithoutNullId); + expect(item).toBeNull(); + }); }); diff --git a/src/app/api/query/utils.ts b/src/app/api/query/utils.ts index c3a72ecf14..1d36f880d7 100644 --- a/src/app/api/query/utils.ts +++ b/src/app/api/query/utils.ts @@ -1,10 +1,20 @@ -import { useMemo } from "react"; - -import type { UseQueryResult } from "@tanstack/react-query"; - -type QueryHook = () => UseQueryResult; +/** + * Selector function to get the count of items in an array. + * @template T + * @returns {function(T[] | undefined): number} A function that takes an array of items and returns the count of items. + */ +export const selectItemsCount = () => { + return (data: T[] | undefined) => data?.length ?? 0; +}; -export const useItemsCount = (useItems: QueryHook) => { - const { data } = useItems(); - return useMemo(() => data?.length ?? 0, [data]); +/** + * Selector function to find an item by its ID. + * @template T + * @param {number | null} id - The ID of the item to find. + * @returns {function(T[]): T | undefined} A function that takes an array of items and returns the item with the specified ID. + */ +export const selectById = ( + id: number | null +) => { + return (data: T[]) => data.find((item) => item.id === id) || null; }; diff --git a/src/app/api/query/zones.test.ts b/src/app/api/query/zones.test.ts index 6fb253c8d4..76bf4f0797 100644 --- a/src/app/api/query/zones.test.ts +++ b/src/app/api/query/zones.test.ts @@ -1,8 +1,8 @@ -import type { JsonBodyType } from "msw"; +import type { UseQueryResult } from "@tanstack/react-query"; +import { type JsonBodyType } from "msw"; -import { useZonesCount } from "./zones"; +import { useZoneCount, useZoneById, useZones } from "./zones"; -import { getFullApiUrl } from "@/app/api/base"; import * as factory from "@/testing/factories"; import { renderHookWithQueryClient, @@ -10,27 +10,61 @@ import { waitFor, } from "@/testing/utils"; -const { server, http, HttpResponse } = setupMockServer(); +const { mockGet } = setupMockServer(); -const setupZonesTest = (mockData: JsonBodyType) => { - server.use( - http.get(getFullApiUrl("zones"), () => HttpResponse.json(mockData)) - ); - return renderHookWithQueryClient(() => useZonesCount()); +const setupTest = ( + hook: () => ReturnType< + typeof useZoneCount | typeof useZoneById | typeof useZones + >, + mockData: JsonBodyType +) => { + mockGet("zones", mockData); + return renderHookWithQueryClient(() => hook()) as { + result: { current: UseQueryResult }; + }; }; -it("should return 0 when zones data is undefined", async () => { - const { result } = setupZonesTest(null); - await waitFor(() => expect(result.current).toBe(0)); +describe("useZones", () => { + it("should return zones data when query succeeds", async () => { + const mockZones = [factory.zone(), factory.zone()]; + const { result } = setupTest(useZones, mockZones); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockZones); + }); }); -it("should return the correct count when zones data is available", async () => { - const mockZonesData = [factory.zone(), factory.zone(), factory.zone()]; - const { result } = setupZonesTest(mockZonesData); - await waitFor(() => expect(result.current).toBe(3)); +describe("useZoneById", () => { + it("should return specific zone when query succeeds", async () => { + const mockZones = [factory.zone({ id: 1 }), factory.zone({ id: 2 })]; + const { result } = setupTest(() => useZoneById(1), mockZones); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockZones[0]); + }); + + it("should return null when zone is not found", async () => { + const mockZones = [factory.zone({ id: 1 })]; + const { result } = setupTest(() => useZoneById(2), mockZones); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); }); -it("should return 0 when zones data is an empty array", async () => { - const { result } = setupZonesTest([]); - await waitFor(() => expect(result.current).toBe(0)); +describe("useZoneCount", () => { + it("should return correct count when query succeeds", async () => { + const mockZones = [factory.zone(), factory.zone(), factory.zone()]; + const { result } = setupTest(useZoneCount, mockZones); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe(3); + }); + + it("should return 0 when zones array is empty", async () => { + const { result } = setupTest(useZoneCount, []); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe(0); + }); }); diff --git a/src/app/api/query/zones.ts b/src/app/api/query/zones.ts index 36d8669ee3..f6e6e02ccc 100644 --- a/src/app/api/query/zones.ts +++ b/src/app/api/query/zones.ts @@ -1,9 +1,19 @@ +import { selectById } from "./utils"; + import { fetchZones } from "@/app/api/endpoints"; import { useWebsocketAwareQuery } from "@/app/api/query/base"; -import { useItemsCount } from "@/app/api/query/utils"; +import type { Zone, ZonePK } from "@/app/store/zone/types"; export const useZones = () => { return useWebsocketAwareQuery(["zones"], fetchZones); }; -export const useZonesCount = () => useItemsCount(useZones); +export const useZoneCount = () => + useWebsocketAwareQuery(["zones"], fetchZones, { + select: (data) => data?.length ?? 0, + }); + +export const useZoneById = (id?: ZonePK | null) => + useWebsocketAwareQuery(["zones"], fetchZones, { + select: selectById(id ?? null), + }); diff --git a/src/app/base/components/NodeConfigurationFields/NodeConfigurationFields.test.tsx b/src/app/base/components/NodeConfigurationFields/NodeConfigurationFields.test.tsx index cf76f86d18..5fda2201ef 100644 --- a/src/app/base/components/NodeConfigurationFields/NodeConfigurationFields.test.tsx +++ b/src/app/base/components/NodeConfigurationFields/NodeConfigurationFields.test.tsx @@ -1,6 +1,4 @@ import { Formik } from "formik"; -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; import configureStore from "redux-mock-store"; import NodeConfigurationFields, { Label } from "./NodeConfigurationFields"; @@ -12,7 +10,12 @@ import type { Tag, TagMeta } from "@/app/store/tag/types"; import { Label as AddTagFormLabel } from "@/app/tags/components/AddTagForm/AddTagForm"; import * as factory from "@/testing/factories"; import { mockFormikFormSaved } from "@/testing/mockFormikFormSaved"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; const mockStore = configureStore(); let state: RootState; @@ -47,14 +50,11 @@ afterEach(() => { it("can open a create tag form", async () => { const store = mockStore(state); - render( - - - - - - - + renderWithBrowserRouter( + + + , + { store } ); await userEvent.type( screen.getByRole("textbox", { name: TagFieldLabel.Input }), @@ -76,14 +76,11 @@ it("does not display automatic tags on the list", async () => { }); state.tag.items = [manualTag, automaticTag]; const store = mockStore(state); - render( - - - - - - - + renderWithBrowserRouter( + + + , + { store } ); await userEvent.click( screen.getByRole("textbox", { name: TagFieldLabel.Input }) @@ -99,17 +96,12 @@ it("does not display automatic tags on the list", async () => { }); it("updates the new tags after creating a tag", async () => { - const store = mockStore(state); const Form = ({ tags }: { tags: Tag[TagMeta.PK][] }) => ( - - - - - - - + + + ); - const { rerender } = render(
); + const { rerender } = renderWithBrowserRouter(, { state }); expect( screen.queryByRole("button", { name: /new-tag/i }) ).not.toBeInTheDocument(); diff --git a/src/app/base/components/ZoneSelect/ZoneSelect.test.tsx b/src/app/base/components/ZoneSelect/ZoneSelect.test.tsx index 13b53c3dc8..ee71cbe279 100644 --- a/src/app/base/components/ZoneSelect/ZoneSelect.test.tsx +++ b/src/app/base/components/ZoneSelect/ZoneSelect.test.tsx @@ -5,45 +5,27 @@ import ZoneSelect from "./ZoneSelect"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { renderWithMockStore, screen } from "@/testing/utils"; +import { renderWithMockStore, screen, setupMockServer } from "@/testing/utils"; const mockStore = configureStore(); +const mockZonesData = [ + factory.zone({ id: 1, name: "Zone 1" }), + factory.zone({ id: 2, name: "Zone 2" }), +]; +const { mockGet } = setupMockServer(); describe("ZoneSelect", () => { - it("renders a list of all zones in state", () => { - const state = factory.rootState({ - zone: factory.zoneState({ - genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [ - factory.zone({ id: 101, name: "Pool 1" }), - factory.zone({ id: 202, name: "Pool 2" }), - ], - }), - }); + it("renders a list of all zones", async () => { + mockGet("zones", mockZonesData); renderWithMockStore( - , - { state } + ); - expect(screen.getByRole("combobox", { name: /zone/i })).toBeInTheDocument(); - }); - - it("dispatches action to fetch zones on load", () => { - const state = factory.rootState(); - const store = mockStore(state); - - renderWithMockStore( - - - , - { store } - ); - expect( - store.getActions().some((action) => action.type === "zone/fetch") - ).toBe(true); + expect(await screen.findByText("Zone 1")).toBeInTheDocument(); + expect(screen.getByText("Zone 2")).toBeInTheDocument(); }); it("disables select if zones have not loaded", () => { diff --git a/src/app/base/components/ZoneSelect/ZoneSelect.tsx b/src/app/base/components/ZoneSelect/ZoneSelect.tsx index 046e837b61..2c7d247bb8 100644 --- a/src/app/base/components/ZoneSelect/ZoneSelect.tsx +++ b/src/app/base/components/ZoneSelect/ZoneSelect.tsx @@ -1,12 +1,9 @@ import type { HTMLProps } from "react"; import { Select } from "@canonical/react-components"; -import { useSelector } from "react-redux"; +import { useZones } from "@/app/api/query/zones"; import FormikField from "@/app/base/components/FormikField"; -import { useFetchActions } from "@/app/base/hooks"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; import type { Zone } from "@/app/store/zone/types"; type Props = { @@ -27,24 +24,21 @@ export const ZoneSelect = ({ valueKey = "name", ...props }: Props): JSX.Element => { - const zones = useSelector(zoneSelectors.all); - const zonesLoaded = useSelector(zoneSelectors.loaded); - - useFetchActions([zoneActions.fetch]); + const zones = useZones(); return ( ({ + ...(zones.data?.map?.((zone) => ({ key: `zone-${zone.id}`, label: zone.name, value: zone[valueKey], - })), + })) || []), ]} {...props} /> diff --git a/src/app/base/components/node/SetZoneForm/SetZoneForm.test.tsx b/src/app/base/components/node/SetZoneForm/SetZoneForm.test.tsx index 55c9163db3..bfec9f2c9a 100644 --- a/src/app/base/components/node/SetZoneForm/SetZoneForm.test.tsx +++ b/src/app/base/components/node/SetZoneForm/SetZoneForm.test.tsx @@ -1,44 +1,42 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import SetZoneForm from "./SetZoneForm"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; - -const mockStore = configureStore(); +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; let state: RootState; +const queryData = { + zones: [ + factory.zone({ id: 0, name: "default" }), + factory.zone({ id: 1, name: "zone-1" }), + ], +}; + beforeEach(() => { state = factory.rootState({ zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [ - factory.zone({ id: 0, name: "default" }), - factory.zone({ id: 1, name: "zone-1" }), - ], }), }); }); it("initialises zone value if exactly one node provided", () => { const nodes = [factory.machine({ zone: factory.modelRef({ id: 1 }) })]; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { state, queryData } ); expect(screen.getByRole("combobox", { name: "Zone" })).toHaveValue("1"); @@ -49,20 +47,16 @@ it("does not initialise zone value if more than one node provided", () => { factory.machine({ zone: factory.modelRef({ id: 0 }) }), factory.machine({ zone: factory.modelRef({ id: 1 }) }), ]; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { state, queryData } ); expect(screen.getByRole("combobox", { name: "Zone" })).toHaveValue(""); @@ -74,20 +68,16 @@ it("correctly runs function to set zones of given nodes", async () => { factory.machine({ system_id: "abc123" }), factory.machine({ system_id: "def456" }), ]; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { state, queryData } ); await userEvent.selectOptions( diff --git a/src/app/base/sagas/http.ts b/src/app/base/sagas/http.ts index 3c5a4666e6..7844daa4e6 100644 --- a/src/app/base/sagas/http.ts +++ b/src/app/base/sagas/http.ts @@ -1,10 +1,7 @@ import type { PayloadAction } from "@reduxjs/toolkit"; -import type { AnyAction } from "redux"; import { call, put, takeEvery, takeLatest } from "typed-redux-saga"; import type { SagaGenerator } from "typed-redux-saga/macro"; -import { isLoaded, setIsLoaded } from "./loaded-endpoints"; - import type { LicenseKeys } from "@/app/store/licensekeys/types"; import type { Script } from "@/app/store/script/types"; import { ScriptResultNames } from "@/app/store/scriptresult/types"; @@ -530,35 +527,6 @@ export function* addMachineChassisSaga( } } -export function* fetchZonesSaga(action: AnyAction): SagaGenerator { - const type = "zone/fetchStart"; - const csrftoken = yield* call(getCookie, "csrftoken"); - if (!csrftoken) { - return; - } - // TODO: add caching for all HTTP requests https://warthogs.atlassian.net/browse/MAASENG-1996 - // Do not fetch again if loaded unless 'nocache' is specified. - if (isLoaded(type) && !action?.meta?.nocache) { - return; - } - let response; - try { - yield* put({ type }); - response = yield* call(api.zones.fetch, csrftoken); - yield* put({ - type: "zone/fetchSuccess", - payload: response, - }); - setIsLoaded(type); - } catch (error) { - yield* put({ - errors: true, - payload: { error: error instanceof Error ? error.message : error }, - type: "zone/fetchError", - }); - } -} - export function* watchExternalLogin(): SagaGenerator { yield* takeLatest("status/externalLogin", externalLoginSaga); } @@ -598,7 +566,3 @@ export function* watchUploadScript(): SagaGenerator { export function* watchAddMachineChassis(): SagaGenerator { yield* takeEvery("machine/addChassis", addMachineChassisSaga); } - -export function* watchZonesFetch(): SagaGenerator { - yield* takeLatest("zone/fetch", fetchZonesSaga); -} diff --git a/src/app/base/sagas/index.ts b/src/app/base/sagas/index.ts index ac6df1cdeb..d90cfaef02 100644 --- a/src/app/base/sagas/index.ts +++ b/src/app/base/sagas/index.ts @@ -10,7 +10,6 @@ import { watchFetchLicenseKeys, watchUploadScript, watchAddMachineChassis, - watchZonesFetch, } from "./http"; import { watchWebSockets } from "./websockets"; @@ -27,5 +26,4 @@ export { watchFetchLicenseKeys, watchUploadScript, watchAddMachineChassis, - watchZonesFetch, }; diff --git a/src/app/base/sagas/websockets/websockets.test.ts b/src/app/base/sagas/websockets/websockets.test.ts index dcf175e9f5..b329209431 100644 --- a/src/app/base/sagas/websockets/websockets.test.ts +++ b/src/app/base/sagas/websockets/websockets.test.ts @@ -23,6 +23,7 @@ import { watchWebSockets, } from "./websockets"; +import type { Config } from "@/app/store/config/types"; import { machineActions } from "@/app/store/machine"; import { getCookie } from "@/app/utils"; import * as factory from "@/testing/factories"; @@ -163,23 +164,23 @@ describe("websocket sagas", () => { it("can send a WebSocket message", () => { const action = { - type: "test/action", + type: "machine/action", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", type: WebSocketMessageType.REQUEST, }, payload: { params: { foo: "bar" }, }, - }; + } as const; const saga = sendMessage(socketClient, action); expect(saga.next().value).toEqual( - put({ meta: { item: { foo: "bar" } }, type: "test/actionStart" }) + put({ meta: { item: { foo: "bar" } }, type: "machine/actionStart" }) ); expect(saga.next().value).toEqual( call([socketClient, socketClient.send], action, { - method: "test.method", + method: "machine.action", type: WebSocketMessageType.REQUEST, params: { foo: "bar" }, }) @@ -195,7 +196,7 @@ describe("websocket sagas", () => { type: WebSocketMessageType.PING, }, payload: null, - }; + } as const; const saga = sendMessage(socketClient, action); expect(saga.next().value).toEqual( put({ @@ -213,27 +214,27 @@ describe("websocket sagas", () => { it("can send a WebSocket message with a request id", () => { const action = { - type: "test/action", + type: "machine/action", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", callId: "123456", type: WebSocketMessageType.REQUEST, }, payload: { params: { foo: "bar" }, }, - }; + } as const; const saga = sendMessage(socketClient, action); expect(saga.next().value).toEqual( put({ meta: { item: { foo: "bar" }, callId: "123456" }, - type: "test/actionStart", + type: "machine/actionStart", }) ); expect(saga.next().value).toEqual( call([socketClient, socketClient.send], action, { - method: "test.method", + method: "machine.action", type: WebSocketMessageType.REQUEST, params: { foo: "bar" }, }) @@ -242,16 +243,16 @@ describe("websocket sagas", () => { it("can store a next action when sending a WebSocket message", () => { const action = { - type: "test/action", + type: "machine/action", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", type: WebSocketMessageType.REQUEST, }, payload: { params: { foo: "bar" }, }, - }; + } as const; const nextActionCreators = [vi.fn()]; return expectSaga(sendMessage, socketClient, action, nextActionCreators) .provide([[matchers.call.fn(socketClient.send), 808]]) @@ -261,16 +262,16 @@ describe("websocket sagas", () => { it("continues if data has already been fetched for list methods", () => { const action = { - type: "test/fetch", + type: "machine/fetch", meta: { - model: "test", - method: "test.list", + model: "machine", + method: "list", type: WebSocketMessageType.REQUEST, }, payload: { params: {}, }, - }; + } as const; const previous = sendMessage(socketClient, action); previous.next(); const saga = sendMessage(socketClient, action); @@ -280,17 +281,17 @@ describe("websocket sagas", () => { it("continues if data has already been fetched for methods with cache", () => { const action = { - type: "test/fetch", + type: "machine/fetch", meta: { cache: true, - model: "test", - method: "test.getAll", + model: "machine", + method: "get", type: WebSocketMessageType.REQUEST, }, payload: { params: {}, }, - }; + } as const; const previous = sendMessage(socketClient, action); previous.next(); const saga = sendMessage(socketClient, action); @@ -300,17 +301,17 @@ describe("websocket sagas", () => { it("fetches list methods if no-cache is set", () => { const action = { - type: "test/fetch", + type: "machine/fetch", meta: { - model: "test", - method: "test.list", + model: "machine", + method: "list", type: WebSocketMessageType.REQUEST, nocache: true, }, payload: { params: {}, }, - }; + } as const; const previous = sendMessage(socketClient, action); previous.next(); const saga = sendMessage(socketClient, action); @@ -320,20 +321,20 @@ describe("websocket sagas", () => { it("can handle dispatching for each param in an array", () => { const action = { - type: "test/action", + type: "config/update", meta: { dispatchMultiple: true, - model: "test", - method: "method", + model: "config", + method: "update", type: WebSocketMessageType.REQUEST, }, payload: { params: [ { name: "foo", value: "bar" }, { name: "baz", value: "qux" }, - ], + ] as Array<{ name: string; value: Config["value"] }>, }, - }; + } as const; const saga = sendMessage(socketClient, action); expect(saga.next().value).toEqual( put({ @@ -343,35 +344,35 @@ describe("websocket sagas", () => { { name: "baz", value: "qux" }, ], }, - type: "test/actionStart", + type: "config/updateStart", }) ); expect(saga.next().value).toEqual( call([socketClient, socketClient.send], action, { - method: "test.method", + method: "config.update", type: WebSocketMessageType.REQUEST, params: { name: "foo", value: "bar" }, }) ); - expect(saga.next().value).toEqual(take("test/actionNotify")); + expect(saga.next().value).toEqual(take("config/updateNotify")); expect(saga.next().value).toEqual( call([socketClient, socketClient.send], action, { - method: "test.method", + method: "config.update", type: WebSocketMessageType.REQUEST, params: { name: "baz", value: "qux" }, }) ); - expect(saga.next().value).toEqual(take("test/actionNotify")); + expect(saga.next().value).toEqual(take("config/updateNotify")); }); it("can handle errors when sending a WebSocket message", () => { const saga = sendMessage(socketClient, { - type: "test/action", + type: "machine/action", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", }, payload: { params: { foo: "bar" }, @@ -383,7 +384,7 @@ describe("websocket sagas", () => { put({ error: true, meta: { item: { foo: "bar" } }, - type: "test/actionError", + type: "machine/actionError", payload: "error!", }) ); @@ -398,14 +399,14 @@ describe("websocket sagas", () => { }).value ).toStrictEqual(call([socketClient, socketClient.getRequest], 99)); saga.next({ - type: "test/action", + type: "machine/action", payload: { id: 808 }, meta: { identifier: 123 }, }); expect(saga.next(false).value).toEqual( put({ meta: { item: { id: 808 }, identifier: 123 }, - type: "test/actionSuccess", + type: "machine/actionSuccess", payload: { response: "here" }, }) ); @@ -420,14 +421,14 @@ describe("websocket sagas", () => { }).value ).toStrictEqual(call([socketClient, socketClient.getRequest], 99)); saga.next({ - type: "test/action", + type: "machine/action", payload: { id: 808 }, meta: { identifier: 123, callId: "456" }, }); expect(saga.next(false).value).toEqual( put({ meta: { item: { id: 808 }, identifier: 123, callId: "456" }, - type: "test/actionSuccess", + type: "machine/actionSuccess", payload: { response: "here" }, }) ); @@ -463,7 +464,7 @@ describe("websocket sagas", () => { }), }).value ).toEqual(call([socketClient, socketClient.getRequest], 99)); - saga.next({ type: "test/action", payload: { id: 808 } }); + saga.next({ type: "machine/action", payload: { id: 808 } }); expect(saga.next(false).value).toEqual( put({ error: true, @@ -471,7 +472,7 @@ describe("websocket sagas", () => { item: { id: 808 }, }, payload: { Message: "catastrophic failure" }, - type: "test/actionError", + type: "machine/actionError", }) ); }); @@ -487,7 +488,7 @@ describe("websocket sagas", () => { }), }).value ).toEqual(call([socketClient, socketClient.getRequest], 99)); - saga.next({ type: "test/action", payload: { id: 808 } }); + saga.next({ type: "machine/action", payload: { id: 808 } }); expect(saga.next(false).value).toEqual( put({ error: true, @@ -495,7 +496,7 @@ describe("websocket sagas", () => { item: { id: 808 }, }, payload: '("catastrophic failure")', - type: "test/actionError", + type: "machine/actionError", }) ); }); @@ -566,18 +567,18 @@ describe("websocket sagas", () => { it("can store a file context action when sending a WebSocket message", () => { const action = { - type: "test/action", + type: "controller/get_summary_xml", meta: { fileContextKey: "file1", - method: "method", - model: "test", + method: "get_summary_xml", + model: "controller", type: WebSocketMessageType.REQUEST, useFileContext: true, }, payload: { params: { system_id: "abc123" }, }, - }; + } as const; return expectSaga(sendMessage, socketClient, action) .provide([[matchers.call.fn(socketClient.send), "abc123"]]) .call(storeFileContextActions, action, ["abc123"]) @@ -607,11 +608,11 @@ describe("websocket sagas", () => { }), }); saga.next({ - type: "test/action", + type: "machine/action", meta: { fileContextKey: "file1", - method: "method", - model: "test", + method: "action", + model: "machine", type: WebSocketMessageType.REQUEST, useFileContext: true, }, @@ -622,7 +623,7 @@ describe("websocket sagas", () => { expect(saga.next(true).value).toEqual( put({ meta: { item: { system_id: "abc123" } }, - type: "test/actionSuccess", + type: "machine/actionSuccess", payload: null, }) ); @@ -644,6 +645,7 @@ describe("websocket sagas", () => { }); return expectSaga( handleUnsubscribe, + // @ts-ignore machineActions.cleanupRequest("123456") ) .withState(state) @@ -668,6 +670,7 @@ describe("websocket sagas", () => { }); return expectSaga( handleUnsubscribe, + // @ts-ignore machineActions.cleanupRequest("123456") ) .withState(state) @@ -680,14 +683,14 @@ describe("websocket sagas", () => { const action = { type: "testAction", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", poll: true, }, payload: { params: {}, }, - }; + } as const; return expectSaga(handlePolling, action) .put({ type: "testActionPollingStarted", @@ -700,14 +703,14 @@ describe("websocket sagas", () => { const action = { type: "testAction", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", pollStop: true, }, payload: { params: {}, }, - }; + } as const; return expectSaga(handlePolling, action) .put({ type: "testActionPollingStopped", @@ -716,8 +719,8 @@ describe("websocket sagas", () => { .dispatch({ type: "testAction", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", pollStop: true, }, payload: null, @@ -729,15 +732,15 @@ describe("websocket sagas", () => { const action = { type: "testAction", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", poll: true, pollId: "poll123", }, payload: { params: {}, }, - }; + } as const; return expectSaga(handlePolling, action) .put({ type: "testActionPollingStarted", @@ -750,15 +753,15 @@ describe("websocket sagas", () => { const action = { type: "testAction", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", pollStop: true, pollId: "poll123", }, payload: { params: {}, }, - }; + } as const; return expectSaga(handlePolling, action) .put({ type: "testActionPollingStopped", @@ -767,8 +770,8 @@ describe("websocket sagas", () => { .dispatch({ type: "testAction", meta: { - model: "test", - method: "method", + model: "machine", + method: "action", pollStop: true, pollId: "poll123", }, @@ -778,16 +781,16 @@ describe("websocket sagas", () => { it("sends the action after the interval", () => { const action = { - type: "testAction", + type: "machine/list", meta: { - model: "test", - method: "method", + model: "machine", + method: "list", poll: true, }, payload: { params: {}, }, - }; + } as const; const saga = pollAction(action); // Skip the delay: saga.next(); diff --git a/src/app/base/sagas/websockets/websockets.ts b/src/app/base/sagas/websockets/websockets.ts index 3d50014ac1..f7373dabbe 100644 --- a/src/app/base/sagas/websockets/websockets.ts +++ b/src/app/base/sagas/websockets/websockets.ts @@ -18,34 +18,40 @@ import { } from "typed-redux-saga"; import type { SagaGenerator } from "typed-redux-saga/macro"; -import type { - WebSocketAction, - WebSocketClient, - WebSocketRequestMessage, - WebSocketActionParams, - WebSocketResponseNotify, - WebSocketResponsePing, -} from "../../../../websocket-client"; -import { WebSocketMessageType } from "../../../../websocket-client"; - import { handleFileContextRequest, storeFileContextActions, } from "./handlers/file-context-requests"; import { isLoaded, resetLoaded, setLoaded } from "./handlers/loaded-endpoints"; -import { handleNextActions, storeNextActions } from "./handlers/next-actions"; import { handlePolling, isStartPollingAction, isStopPollingAction, } from "./handlers/polling-requests"; -import { handleUnsubscribe, isUnsubscribeAction } from "./handlers/unsubscribe"; import type { MessageHandler, NextActionCreator, } from "@/app/base/sagas/actions"; +import { + handleNextActions, + storeNextActions, +} from "@/app/base/sagas/websockets/handlers/next-actions"; +import { + handleUnsubscribe, + isUnsubscribeAction, +} from "@/app/base/sagas/websockets/handlers/unsubscribe"; import type { GenericMeta } from "@/app/store/utils/slice"; +import { WebSocketMessageType } from "@/websocket-client"; +import type { + WebSocketAction, + WebSocketClient, + WebSocketRequestMessage, + WebSocketActionParams, + WebSocketResponseNotify, + WebSocketResponsePing, + WebSocketEndpoint, +} from "@/websocket-client"; export type WebSocketChannel = EventChannel< | ReconnectingWebSocketEvent @@ -330,7 +336,7 @@ export function* sendMessage( const { meta, payload, type } = action; const params = payload ? payload.params : null; const { cache, identifier, method, model, nocache } = meta; - const endpoint = `${model}.${method}`; + const endpoint: WebSocketEndpoint = `${model}.${method}`; const hasMultipleDispatches = meta.dispatchMultiple && Array.isArray(params); // If method is 'list' and data has loaded/is loading, do not fetch again // unless 'nocache' is specified. diff --git a/src/app/base/websocket-context.tsx b/src/app/base/websocket-context.tsx new file mode 100644 index 0000000000..06c4f91b04 --- /dev/null +++ b/src/app/base/websocket-context.tsx @@ -0,0 +1,16 @@ +import React, { createContext } from "react"; + +import { websocketClient } from "@/redux-store"; +import type WebSocketClient from "@/websocket-client"; + +export const WebSocketContext = createContext(null); + +export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx b/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx index 632ef88b2b..d2b6a4e962 100644 --- a/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx +++ b/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx @@ -1,7 +1,3 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import ControllerConfiguration from "./ControllerConfiguration"; import { Label as ConfigurationLabel } from "./ControllerConfigurationForm"; import { Label as PowerConfigurationLabel } from "./ControllerPowerConfiguration"; @@ -10,15 +6,27 @@ import { Labels as EditableSectionLabels } from "@/app/base/components/EditableS import { Label as NodeConfigurationFieldsLabel } from "@/app/base/components/NodeConfigurationFields/NodeConfigurationFields"; import { Label as TagFieldLabel } from "@/app/base/components/TagField/TagField"; import { Label as ZoneSelectLabel } from "@/app/base/components/ZoneSelect/ZoneSelect"; +import urls from "@/app/base/urls"; import { controllerActions } from "@/app/store/controller"; import { PodType } from "@/app/store/pod/constants"; -import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; -const mockStore = configureStore(); -let state: RootState; const controller = factory.controllerDetails({ system_id: "abc123" }); +const route = urls.controllers.controller.index({ id: controller.system_id }); + +let state: ReturnType; +const queryData = { + zones: [ + factory.zone({ id: 1, name: "default" }), + factory.zone({ id: 2, name: "twilight" }), + ], +}; beforeEach(() => { state = factory.rootState({ @@ -51,21 +59,17 @@ beforeEach(() => { factory.tag({ id: 2, name: "tag2" }), ], }), - zone: factory.zoneState({ - genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ name: "twilight" })], - }), }); }); it("displays controller configuration sections", async () => { - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { + state, + queryData, + route, + } ); expect( @@ -78,28 +82,27 @@ it("displays controller configuration sections", async () => { it("displays a loading indicator if the controller has not loaded", () => { state.controller.items = []; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { + state, + queryData, + route, + } ); - expect( screen.getByRole("alert", { name: /loading controller configuration/ }) ).toBeInTheDocument(); }); it("displays non-editable controller details by default", () => { - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { + state, + queryData, + route, + } ); expect( @@ -117,13 +120,13 @@ it("displays non-editable controller details by default", () => { }); it("can switch to controller configuration forms", async () => { - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { + state, + queryData, + route, + } ); await userEvent.click( @@ -153,13 +156,13 @@ it("can switch to controller configuration forms", async () => { }); it("correctly dispatches an action to update a controller", async () => { - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + route, + } ); await userEvent.click( screen.getAllByRole("button", { @@ -198,14 +201,14 @@ it("correctly dispatches an action to update a controller", async () => { }); it("displays an alert on edit when controller manages more than 1 node", async () => { - const store = mockStore(state); state.controller.items = [{ ...controller, power_bmc_node_count: 3 }]; - render( - - - - - + renderWithBrowserRouter( + , + { + state, + queryData, + route, + } ); await userEvent.click( screen.getAllByRole("button", { diff --git a/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.test.tsx b/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.test.tsx index 07f415de34..3567f125bd 100644 --- a/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.test.tsx +++ b/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.test.tsx @@ -7,7 +7,6 @@ import { DeviceIpAssignment } from "@/app/store/device/types"; import { domainActions } from "@/app/store/domain"; import type { RootState } from "@/app/store/root/types"; import { subnetActions } from "@/app/store/subnet"; -import { zoneActions } from "@/app/store/zone"; import * as factory from "@/testing/factories"; import { userEvent, @@ -21,7 +20,7 @@ const mockStore = configureStore(); describe("AddDeviceForm", () => { let state: RootState; - + const queryData = { zones: [factory.zone({ id: 0, name: "default" })] }; beforeEach(() => { state = factory.rootState({ domain: factory.domainState({ @@ -36,22 +35,20 @@ describe("AddDeviceForm", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ id: 0, name: "default" })], }), }); }); it("fetches the necessary data on load", () => { - const store = mockStore(state); - renderWithBrowserRouter(, { - store, - }); + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + } + ); - const expectedActions = [ - domainActions.fetch(), - subnetActions.fetch(), - zoneActions.fetch(), - ]; + const expectedActions = [domainActions.fetch(), subnetActions.fetch()]; const actualActions = store.getActions(); expectedActions.forEach((expectedAction) => { expect( @@ -67,6 +64,7 @@ describe("AddDeviceForm", () => { const store = mockStore(state); renderWithBrowserRouter(, { store, + queryData: {}, }); expect(screen.getByText(/Loading/)).toBeInTheDocument(); @@ -76,6 +74,7 @@ describe("AddDeviceForm", () => { const store = mockStore(state); renderWithBrowserRouter(, { store, + queryData, }); await userEvent.type( diff --git a/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.tsx b/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.tsx index aa20405603..5fc54d6a4e 100644 --- a/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.tsx +++ b/src/app/devices/components/DeviceHeaderForms/AddDeviceForm/AddDeviceForm.tsx @@ -9,6 +9,7 @@ import * as Yup from "yup"; import AddDeviceInterfaces from "./AddDeviceInterfaces"; import type { AddDeviceValues } from "./types"; +import { useZones } from "@/app/api/query/zones"; import DomainSelect from "@/app/base/components/DomainSelect"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; @@ -24,8 +25,6 @@ import { domainActions } from "@/app/store/domain"; import domainSelectors from "@/app/store/domain/selectors"; import { subnetActions } from "@/app/store/subnet"; import subnetSelectors from "@/app/store/subnet/selectors"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; import { isIpInSubnet } from "@/app/utils/subnetIpRange"; type Props = { @@ -130,18 +129,13 @@ export const AddDeviceForm = ({ const domains = useSelector(domainSelectors.all); const domainsLoaded = useSelector(domainSelectors.loaded); const subnetsLoaded = useSelector(subnetSelectors.loaded); - const zones = useSelector(zoneSelectors.all); - const zonesLoaded = useSelector(zoneSelectors.loaded); + const zones = useZones(); const [secondarySubmit, setSecondarySubmit] = useState(false); const [savingDevice, setSavingDevice] = useState(null); // Fetch all data required for the form. - useFetchActions([ - domainActions.fetch, - subnetActions.fetch, - zoneActions.fetch, - ]); + useFetchActions([domainActions.fetch, subnetActions.fetch]); useAddMessage( devicesSaved, @@ -150,7 +144,7 @@ export const AddDeviceForm = ({ () => setSavingDevice(null) ); - const loaded = domainsLoaded && subnetsLoaded && zonesLoaded; + const loaded = domainsLoaded && subnetsLoaded && !zones.isPending; if (!loaded) { return ( @@ -180,7 +174,7 @@ export const AddDeviceForm = ({ subnet_cidr: "", }, ], - zone: (zones.length && zones[0].name) || "", + zone: zones.data?.length ? zones.data[0].name : "", }} onCancel={clearSidePanelContent} onSaveAnalytics={{ diff --git a/src/app/devices/components/DeviceHeaderForms/DeviceHeaderForms.test.tsx b/src/app/devices/components/DeviceHeaderForms/DeviceHeaderForms.test.tsx index 17eec79962..91cdaebab8 100644 --- a/src/app/devices/components/DeviceHeaderForms/DeviceHeaderForms.test.tsx +++ b/src/app/devices/components/DeviceHeaderForms/DeviceHeaderForms.test.tsx @@ -1,16 +1,12 @@ -import configureStore from "redux-mock-store"; - import DeviceHeaderForms from "./DeviceHeaderForms"; import { DeviceSidePanelViews } from "@/app/devices/constants"; -import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { screen, renderWithBrowserRouter } from "@/testing/utils"; -const mockStore = configureStore(); - describe("DeviceHeaderForms", () => { it("can render the Add Device form", () => { + const queryData = { zones: [factory.zone({ id: 0, name: "default" })] }; const state = factory.rootState({ domain: factory.domainState({ items: [factory.domain({ id: 0, name: "maas" })], @@ -22,17 +18,15 @@ describe("DeviceHeaderForms", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ id: 0, name: "default" })], }), }); - const store = mockStore(state); renderWithBrowserRouter( , - { store } + { state, queryData } ); expect( diff --git a/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.test.tsx b/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.test.tsx index 120071de90..d6a1eac53f 100644 --- a/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.test.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.test.tsx @@ -1,7 +1,3 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import DeviceConfiguration, { Label } from "./DeviceConfiguration"; import { Labels as EditableSectionLabels } from "@/app/base/components/EditableSection"; @@ -11,12 +7,16 @@ import { Label as ZoneSelectLabel } from "@/app/base/components/ZoneSelect/ZoneS import { deviceActions } from "@/app/store/device"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; - -const mockStore = configureStore(); +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; describe("DeviceConfiguration", () => { let state: RootState; + const queryData = { zones: [factory.zone({ name: "twilight" })] }; beforeEach(() => { state = factory.rootState({ @@ -32,34 +32,25 @@ describe("DeviceConfiguration", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ name: "twilight" })], }), }); }); it("displays a spinner if the device has not loaded yet", () => { state.device.items = []; - const store = mockStore(state); - render( - - - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + }); expect(screen.getByTestId("loading-device")).toBeInTheDocument(); }); it("shows the device details by default", () => { - const store = mockStore(state); - render( - - - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + }); expect(screen.getByTestId("device-details")).toBeInTheDocument(); expect( @@ -68,14 +59,10 @@ describe("DeviceConfiguration", () => { }); it("can switch to showing the device configuration form", async () => { - const store = mockStore(state); - render( - - - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + }); await userEvent.click( screen.getAllByRole("button", { @@ -88,13 +75,12 @@ describe("DeviceConfiguration", () => { }); it("correctly dispatches an action to update a device", async () => { - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + } ); await userEvent.click( screen.getAllByRole("button", { diff --git a/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.tsx b/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.tsx index 5e6aebbc24..be08332967 100644 --- a/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceConfiguration/DeviceConfiguration.tsx @@ -1,8 +1,7 @@ -import { useEffect } from "react"; - import { Spinner, Strip } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; +import { useZones } from "@/app/api/query/zones"; import Definition from "@/app/base/components/Definition"; import EditableSection from "@/app/base/components/EditableSection"; import FormikForm from "@/app/base/components/FormikForm"; @@ -19,8 +18,6 @@ import type { Device, DeviceMeta } from "@/app/store/device/types"; import { FilterDevices, isDeviceDetails } from "@/app/store/device/utils"; import type { RootState } from "@/app/store/root/types"; import tagSelectors from "@/app/store/tag/selectors"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { systemId: Device[DeviceMeta.PK]; @@ -45,14 +42,10 @@ const DeviceConfiguration = ({ systemId }: Props): JSX.Element => { const deviceTags = useSelector((state: RootState) => tagSelectors.getByIDs(state, device?.tags || null) ); - const zonesLoaded = useSelector(zoneSelectors.loaded); - const loaded = isDeviceDetails(device) && zonesLoaded; + const zones = useZones(); + const loaded = isDeviceDetails(device) && !zones.isPending; useWindowTitle(`${`${device?.hostname}` || "Device"} configuration`); - useEffect(() => { - dispatch(zoneActions.fetch()); - }); - if (!loaded) { return ( diff --git a/src/app/devices/views/DeviceDetails/DeviceDetails.test.tsx b/src/app/devices/views/DeviceDetails/DeviceDetails.test.tsx index 7ef5809c49..acc60a3a9a 100644 --- a/src/app/devices/views/DeviceDetails/DeviceDetails.test.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceDetails.test.tsx @@ -1,5 +1,3 @@ -import configureStore from "redux-mock-store"; - import { Label as DeviceConfigurationLabel } from "./DeviceConfiguration/DeviceConfiguration"; import DeviceDetails from "./DeviceDetails"; import { Label as DeviceNetworkLabel } from "./DeviceNetwork/DeviceNetwork"; @@ -11,8 +9,6 @@ import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { screen, renderWithBrowserRouter } from "@/testing/utils"; -const mockStore = configureStore(); - describe("DeviceDetails", () => { const device = factory.deviceDetails({ system_id: "abc123" }); let state: RootState; @@ -44,13 +40,13 @@ describe("DeviceDetails", () => { path: urls.devices.device.configuration({ id: "abc123" }), }, ].forEach(({ label, path }) => { - it(`Displays: ${label} at: ${path}`, () => { + it(`Displays: ${label} at: ${path}`, async () => { renderWithBrowserRouter(, { route: path, state, routePattern: `${urls.devices.device.index(null)}/*`, }); - expect(screen.getByLabelText(label)).toBeInTheDocument(); + expect(await screen.findByLabelText(label)).toBeInTheDocument(); }); }); @@ -66,10 +62,9 @@ describe("DeviceDetails", () => { }); it("gets and sets the device as active", () => { - const store = mockStore(state); - renderWithBrowserRouter(, { + const { store } = renderWithBrowserRouter(, { route: urls.devices.device.index({ id: device.system_id }), - store, + state, routePattern: `${urls.devices.device.index(null)}/*`, }); @@ -88,10 +83,9 @@ describe("DeviceDetails", () => { }); it("unsets active device and cleans up when unmounting", () => { - const store = mockStore(state); - const { unmount } = renderWithBrowserRouter(, { + const { unmount, store } = renderWithBrowserRouter(, { route: urls.devices.device.index({ id: device.system_id }), - store, + state, routePattern: `${urls.devices.device.index(null)}/*`, }); @@ -116,10 +110,9 @@ describe("DeviceDetails", () => { it("displays a message if the device does not exist", () => { state.device.items = []; - const store = mockStore(state); renderWithBrowserRouter(, { route: urls.devices.device.index({ id: device.system_id }), - store, + state, routePattern: `${urls.devices.device.index(null)}/*`, }); diff --git a/src/app/domains/views/DomainsList/DomainsList.tsx b/src/app/domains/views/DomainsList/DomainsList.tsx index 846d3219e1..26fc4c5ab6 100644 --- a/src/app/domains/views/DomainsList/DomainsList.tsx +++ b/src/app/domains/views/DomainsList/DomainsList.tsx @@ -3,20 +3,20 @@ import { useSelector } from "react-redux"; import DomainListHeader from "./DomainListHeader"; import DomainsTable from "./DomainsTable"; +import { useZones } from "@/app/api/query/zones"; import PageContent from "@/app/base/components/PageContent"; -import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; +import { useWindowTitle } from "@/app/base/hooks"; import { useSidePanel } from "@/app/base/side-panel-context"; import DomainForm from "@/app/domains/components/DomainForm"; import domainsSelectors from "@/app/store/domain/selectors"; import { getSidePanelTitle } from "@/app/store/utils/node/base"; -import { zoneActions } from "@/app/store/zone"; + const DomainsList = (): JSX.Element => { const domains = useSelector(domainsSelectors.all); const { sidePanelContent, setSidePanelContent } = useSidePanel(); useWindowTitle("DNS"); - - useFetchActions([zoneActions.fetch]); + useZones(); return ( { state = factory.rootState({ @@ -25,7 +27,6 @@ beforeEach(() => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ id: 3 })], }), }); }); @@ -36,15 +37,13 @@ it("can handle updating a lxd KVM", async () => { tags: ["tag1", "tag2"], type: PodType.LXD, }); - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + route: "/kvm/1/edit", + queryData, + state, + } ); await userEvent.selectOptions( @@ -89,15 +88,13 @@ it("can handle updating a virsh KVM", async () => { tags: ["tag1", "tag2"], type: PodType.VIRSH, }); - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + route: "/kvm/1/edit", + queryData, + state, + } ); await userEvent.selectOptions( @@ -146,15 +143,13 @@ it("enables the submit button if form values are different to pod values", async cpu_over_commit_ratio: 1, id: 1, }); - const store = mockStore(state); - const { rerender } = render( - - - - - + const { rerender } = renderWithBrowserRouter( + , + { + route: "/kvm/1/edit", + queryData, + state, + } ); // Submit should be disabled by default. @@ -177,15 +172,7 @@ it("enables the submit button if form values are different to pod values", async ...pod, cpu_over_commit_ratio: pod.cpu_over_commit_ratio + 1, }; - rerender( - - - - - - ); + rerender(); // Submit should be disabled again. expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); diff --git a/src/app/kvm/components/KVMConfigurationCard/KVMConfigurationCardFields/KVMConfigurationCardFields.test.tsx b/src/app/kvm/components/KVMConfigurationCard/KVMConfigurationCardFields/KVMConfigurationCardFields.test.tsx index 1783a4a7a4..6639daffb5 100644 --- a/src/app/kvm/components/KVMConfigurationCard/KVMConfigurationCardFields/KVMConfigurationCardFields.test.tsx +++ b/src/app/kvm/components/KVMConfigurationCard/KVMConfigurationCardFields/KVMConfigurationCardFields.test.tsx @@ -7,7 +7,7 @@ import { renderWithBrowserRouter, screen, within } from "@/testing/utils"; describe("KVMConfigurationCardFields", () => { let state: RootState; - + const queryData = { zones: [factory.zone({ id: 1, name: "zone-1" })] }; beforeEach(() => { state = factory.rootState({ pod: factory.podState({ items: [], loaded: true }), @@ -15,9 +15,6 @@ describe("KVMConfigurationCardFields", () => { loaded: true, items: [factory.resourcePool({ id: 1, name: "pool-1" })], }), - zone: factory.zoneState({ - items: [factory.zone({ id: 1, name: "zone-1" })], - }), }); }); @@ -36,6 +33,7 @@ describe("KVMConfigurationCardFields", () => { renderWithBrowserRouter(, { route: "/kvm/1/edit", state, + queryData, }); expect(screen.getByRole("textbox", { name: "KVM host type" })).toHaveValue( @@ -78,6 +76,7 @@ describe("KVMConfigurationCardFields", () => { renderWithBrowserRouter(, { route: "/kvm/1/edit", state, + queryData, }); expect(screen.getByRole("textbox", { name: "KVM host type" })).toHaveValue( "LXD" diff --git a/src/app/kvm/components/KVMForms/AddLxd/AddLxd.test.tsx b/src/app/kvm/components/KVMForms/AddLxd/AddLxd.test.tsx index e9cbafc66e..367bcc04d0 100644 --- a/src/app/kvm/components/KVMForms/AddLxd/AddLxd.test.tsx +++ b/src/app/kvm/components/KVMForms/AddLxd/AddLxd.test.tsx @@ -15,7 +15,9 @@ import { } from "@/testing/utils"; const mockStore = configureStore(); - +const queryData = { + zones: [factory.zone({ id: 0 }), factory.zone({ id: 1 })], +}; describe("AddLxd", () => { let state: RootState; @@ -48,9 +50,6 @@ describe("AddLxd", () => { items: [factory.resourcePool({ id: 0 })], loaded: true, }), - zone: factory.zoneState({ - items: [factory.zone({ id: 0 })], - }), }); }); @@ -82,6 +81,9 @@ describe("AddLxd", () => { renderWithBrowserRouter(, { route: "/kvm/add", state, + queryData: { + zones: [factory.zone({ id: 0 }), factory.zone({ id: 1 })], + }, }); // Submit credentials form @@ -134,6 +136,7 @@ describe("AddLxd", () => { renderWithBrowserRouter(, { route: "/kvm/add", state, + queryData, }); // Submit credentials form @@ -198,6 +201,7 @@ describe("AddLxd", () => { renderWithBrowserRouter(, { route: "/kvm/add", state, + queryData, }); // Submit credentials form diff --git a/src/app/kvm/components/KVMForms/AddLxd/AddLxd.tsx b/src/app/kvm/components/KVMForms/AddLxd/AddLxd.tsx index 177e95fc20..da3533b008 100644 --- a/src/app/kvm/components/KVMForms/AddLxd/AddLxd.tsx +++ b/src/app/kvm/components/KVMForms/AddLxd/AddLxd.tsx @@ -9,10 +9,10 @@ import CredentialsForm from "./CredentialsForm"; import SelectProjectForm from "./SelectProjectForm"; import type { AddLxdStepValues, NewPodValues } from "./types"; +import { useZones } from "@/app/api/query/zones"; import type { ClearSidePanelContent } from "@/app/base/types"; import { podActions } from "@/app/store/pod"; import resourcePoolSelectors from "@/app/store/resourcepool/selectors"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { clearSidePanelContent: ClearSidePanelContent; @@ -27,7 +27,7 @@ export const AddLxdSteps = { export const AddLxd = ({ clearSidePanelContent }: Props): JSX.Element => { const dispatch = useDispatch(); const resourcePools = useSelector(resourcePoolSelectors.all); - const zones = useSelector(zoneSelectors.all); + const zones = useZones(); const [step, setStep] = useState(AddLxdSteps.CREDENTIALS); const stepIndex = Object.values(AddLxdSteps).indexOf(step); const [submissionErrors, setSubmissionErrors] = useState(null); @@ -38,7 +38,7 @@ export const AddLxd = ({ clearSidePanelContent }: Props): JSX.Element => { password: "", pool: resourcePools.length ? `${resourcePools[0].id}` : "", power_address: "", - zone: zones.length ? `${zones[0].id}` : "", + zone: zones.data?.length ? `${zones.data[0].id}` : "", }); // We run the cleanup function here rather than in each form component diff --git a/src/app/kvm/components/KVMForms/AddLxd/AuthenticationForm/AuthenticationForm.test.tsx b/src/app/kvm/components/KVMForms/AddLxd/AuthenticationForm/AuthenticationForm.test.tsx index c3897d3f5a..0d14448c80 100644 --- a/src/app/kvm/components/KVMForms/AddLxd/AuthenticationForm/AuthenticationForm.test.tsx +++ b/src/app/kvm/components/KVMForms/AddLxd/AuthenticationForm/AuthenticationForm.test.tsx @@ -17,6 +17,9 @@ const mockStore = configureStore(); describe("AuthenticationForm", () => { let state: RootState; let newPodValues: NewPodValues; + const queryData = { + zones: [factory.zone()], + }; beforeEach(() => { state = factory.rootState({ @@ -32,9 +35,6 @@ describe("AuthenticationForm", () => { items: [factory.resourcePool()], loaded: true, }), - zone: factory.zoneState({ - items: [factory.zone()], - }), }); newPodValues = { certificate: "", @@ -59,7 +59,7 @@ describe("AuthenticationForm", () => { setNewPodValues={vi.fn()} setStep={vi.fn()} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); // Trusting via certificate is selected by default, so spinner should show // after submitting the form. @@ -164,7 +164,7 @@ describe("AuthenticationForm", () => { setNewPodValues={vi.fn()} setStep={setStep} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); // Change to trusting via password and submit the form. await userEvent.click( @@ -188,7 +188,7 @@ describe("AuthenticationForm", () => { setNewPodValues={vi.fn()} setStep={setStep} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); await userEvent.click( screen.getByRole("button", { name: "Check authentication" }) @@ -208,7 +208,7 @@ describe("AuthenticationForm", () => { setNewPodValues={vi.fn()} setStep={setStep} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); await userEvent.click( screen.getByRole("button", { name: "Check authentication" }) @@ -232,7 +232,7 @@ describe("AuthenticationForm", () => { setNewPodValues={vi.fn()} setStep={setStep} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); expect(setStep).toHaveBeenCalledWith(AddLxdSteps.SELECT_PROJECT); diff --git a/src/app/kvm/components/KVMForms/AddLxd/CredentialsForm/CredentialsForm.test.tsx b/src/app/kvm/components/KVMForms/AddLxd/CredentialsForm/CredentialsForm.test.tsx index ff95413411..a1d23e75ea 100644 --- a/src/app/kvm/components/KVMForms/AddLxd/CredentialsForm/CredentialsForm.test.tsx +++ b/src/app/kvm/components/KVMForms/AddLxd/CredentialsForm/CredentialsForm.test.tsx @@ -17,6 +17,9 @@ const mockStore = configureStore(); describe("CredentialsForm", () => { let state: RootState; let newPodValues: NewPodValues; + const queryData = { + zones: [factory.zone()], + }; beforeEach(() => { state = factory.rootState({ @@ -32,9 +35,6 @@ describe("CredentialsForm", () => { items: [factory.resourcePool()], loaded: true, }), - zone: factory.zoneState({ - items: [factory.zone()], - }), }); newPodValues = { certificate: "", @@ -58,7 +58,7 @@ describe("CredentialsForm", () => { setStep={vi.fn()} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); // Submit form @@ -103,7 +103,7 @@ describe("CredentialsForm", () => { setStep={vi.fn()} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); // Change radio to provide certificate instead of generating one. await userEvent.click( @@ -169,7 +169,7 @@ describe("CredentialsForm", () => { setStep={setStep} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); expect(setStep).toHaveBeenCalledWith(AddLxdSteps.AUTHENTICATION); @@ -199,7 +199,7 @@ describe("CredentialsForm", () => { setStep={setStep} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); expect(setStep).not.toHaveBeenCalled(); @@ -227,7 +227,7 @@ describe("CredentialsForm", () => { setStep={setStep} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); expect(setStep).toHaveBeenCalledWith(AddLxdSteps.SELECT_PROJECT); @@ -257,7 +257,7 @@ describe("CredentialsForm", () => { setStep={setStep} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); expect(setStep).not.toHaveBeenCalled(); @@ -294,7 +294,7 @@ describe("CredentialsForm", () => { setStep={setStep} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); expect(setStep).not.toHaveBeenCalled(); expect(screen.getByTestId("notification-title")).toHaveTextContent( @@ -326,7 +326,7 @@ describe("CredentialsForm", () => { setStep={vi.fn()} setSubmissionErrors={setSubmissionErrors} />, - { route: "/kvm/add", store } + { route: "/kvm/add", store, queryData } ); unmount(); expect( diff --git a/src/app/kvm/components/KVMForms/AddLxd/SelectProjectForm/SelectProjectForm.test.tsx b/src/app/kvm/components/KVMForms/AddLxd/SelectProjectForm/SelectProjectForm.test.tsx index b54cc6bbe5..98730c27f3 100644 --- a/src/app/kvm/components/KVMForms/AddLxd/SelectProjectForm/SelectProjectForm.test.tsx +++ b/src/app/kvm/components/KVMForms/AddLxd/SelectProjectForm/SelectProjectForm.test.tsx @@ -1,5 +1,3 @@ -import configureStore from "redux-mock-store"; - import { AddLxdSteps } from "../AddLxd"; import type { NewPodValues } from "../types"; @@ -16,12 +14,12 @@ import { fireEvent, } from "@/testing/utils"; -const mockStore = configureStore(); - describe("SelectProjectForm", () => { let state: RootState; let newPodValues: NewPodValues; - + const queryData = { + zones: [factory.zone()], + }; beforeEach(() => { state = factory.rootState({ pod: factory.podState({ @@ -31,9 +29,6 @@ describe("SelectProjectForm", () => { items: [factory.resourcePool()], loaded: true, }), - zone: factory.zoneState({ - items: [factory.zone()], - }), }); newPodValues = { certificate: "certificate", @@ -78,7 +73,7 @@ describe("SelectProjectForm", () => { setStep={vi.fn()} setSubmissionErrors={vi.fn()} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); const nameInput = screen.getByRole("textbox", { @@ -97,16 +92,15 @@ describe("SelectProjectForm", () => { state.pod.projects = { "192.168.1.1": [project], }; - const store = mockStore(state); - renderWithBrowserRouter( + const { store } = renderWithBrowserRouter( , - { route: "/kvm/add", store } + { route: "/kvm/add", state, queryData } ); const nameInput = screen.getByRole("textbox", { @@ -141,16 +135,15 @@ describe("SelectProjectForm", () => { state.pod.projects = { "192.168.1.1": [project], }; - const store = mockStore(state); - renderWithBrowserRouter( + const { store } = renderWithBrowserRouter( , - { route: "/kvm/add", store } + { route: "/kvm/add", state, queryData } ); await userEvent.click( @@ -191,7 +184,7 @@ describe("SelectProjectForm", () => { setStep={setStep} setSubmissionErrors={setSubmissionErrors} />, - { route: "/kvm/add", state } + { route: "/kvm/add", state, queryData } ); expect(setStep).toHaveBeenCalledWith(AddLxdSteps.CREDENTIALS); diff --git a/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.test.tsx b/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.test.tsx index ba16b0ebb5..c9dacc090e 100644 --- a/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.test.tsx +++ b/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.test.tsx @@ -1,5 +1,3 @@ -import configureStore from "redux-mock-store"; - import AddVirsh from "./AddVirsh"; import { ConfigNames } from "@/app/store/config/types"; @@ -7,11 +5,13 @@ import { generalActions } from "@/app/store/general"; import { PodType } from "@/app/store/pod/constants"; import { resourcePoolActions } from "@/app/store/resourcepool"; import type { RootState } from "@/app/store/root/types"; -import { zoneActions } from "@/app/store/zone"; import * as factory from "@/testing/factories"; -import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; - -const mockStore = configureStore(); +import { + renderWithBrowserRouter, + screen, + userEvent, + waitFor, +} from "@/testing/utils"; describe("AddVirsh", () => { let state: RootState; @@ -44,21 +44,24 @@ describe("AddVirsh", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ id: 0 })], }), }); }); it("fetches the necessary data on load", () => { - const store = mockStore(state); - renderWithBrowserRouter(, { - route: "/kvm/add", - store, - }); + const { store } = renderWithBrowserRouter( + , + { + route: "/kvm/add", + state, + queryData: { + zones: [factory.zone({ id: 0 })], + }, + } + ); const expectedActions = [ generalActions.fetchPowerTypes(), resourcePoolActions.fetch(), - zoneActions.fetch(), ]; const actualActions = store.getActions(); expectedActions.forEach((expectedAction) => { @@ -72,10 +75,9 @@ describe("AddVirsh", () => { it("displays a spinner if data hasn't loaded yet", () => { state.general.powerTypes.loaded = false; - const store = mockStore(state); renderWithBrowserRouter(, { route: "/kvm/add", - store, + state, }); expect(screen.getByText(/Loading/i)).toBeInTheDocument(); }); @@ -83,26 +85,32 @@ describe("AddVirsh", () => { it("displays a message if virsh is not supported", () => { state.general.powerTypes.data = []; state.general.powerTypes.loaded = true; - const store = mockStore(state); renderWithBrowserRouter(, { route: "/kvm/add", - store, + state, }); expect(screen.getByTestId("virsh-unsupported")).toBeInTheDocument(); }); it("can handle saving a virsh KVM", async () => { - const store = mockStore(state); - renderWithBrowserRouter(, { - route: "/kvm/add", - store, + const { store } = renderWithBrowserRouter( + , + { + route: "/kvm/add", + state, + queryData: { zones: [factory.zone({ id: 0 })] }, + } + ); + + await waitFor(() => { + expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument(); }); await userEvent.type( - screen.getByRole("textbox", { name: /Name/i }), + await screen.findByRole("textbox", { name: /Name/i }), "my-favourite-kvm" ); await userEvent.selectOptions( - screen.getByRole("combobox", { name: /Resource pool/i }), + await screen.findByRole("combobox", { name: /Resource pool/i }), "0" ); await userEvent.selectOptions(screen.getByLabelText(/Zone/i), "0"); diff --git a/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.tsx b/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.tsx index 962bf4d6d7..f1d050898d 100644 --- a/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.tsx +++ b/src/app/kvm/components/KVMForms/AddVirsh/AddVirsh.tsx @@ -7,6 +7,7 @@ import * as Yup from "yup"; import AddVirshFields from "./AddVirshFields"; +import { useZones } from "@/app/api/query/zones"; import FormikForm from "@/app/base/components/FormikForm"; import { useFetchActions, useAddMessage } from "@/app/base/hooks"; import type { ClearSidePanelContent } from "@/app/base/types"; @@ -25,8 +26,6 @@ import type { Pod } from "@/app/store/pod/types"; import { resourcePoolActions } from "@/app/store/resourcepool"; import resourcePoolSelectors from "@/app/store/resourcepool/selectors"; import type { PowerParameters } from "@/app/store/types/node"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { clearSidePanelContent: ClearSidePanelContent; @@ -49,18 +48,13 @@ export const AddVirsh = ({ clearSidePanelContent }: Props): JSX.Element => { const powerTypesLoaded = useSelector(powerTypesSelectors.loaded); const resourcePools = useSelector(resourcePoolSelectors.all); const resourcePoolsLoaded = useSelector(resourcePoolSelectors.loaded); - const zones = useSelector(zoneSelectors.all); - const zonesLoaded = useSelector(zoneSelectors.loaded); + const zones = useZones(); const [savingPod, setSavingPod] = useState(null); const cleanup = useCallback(() => podActions.cleanup(), []); const initialPowerParameters = useInitialPowerParameters(); - const loaded = powerTypesLoaded && resourcePoolsLoaded && zonesLoaded; + const loaded = powerTypesLoaded && resourcePoolsLoaded && !zones.isPending; - useFetchActions([ - generalActions.fetchPowerTypes, - resourcePoolActions.fetch, - zoneActions.fetch, - ]); + useFetchActions([generalActions.fetchPowerTypes, resourcePoolActions.fetch]); useAddMessage( podSaved, @@ -104,7 +98,7 @@ export const AddVirsh = ({ clearSidePanelContent }: Props): JSX.Element => { pool: resourcePools.length ? resourcePools[0].id : "", power_parameters: initialPowerParameters, type: PodType.VIRSH, - zone: zones.length ? zones[0].id : "", + zone: zones.data?.length ? zones.data[0].id : "", }} onCancel={clearSidePanelContent} onSaveAnalytics={{ diff --git a/src/app/kvm/components/KVMForms/AddVirsh/AddVirshFields/AddVirshFields.test.tsx b/src/app/kvm/components/KVMForms/AddVirsh/AddVirshFields/AddVirshFields.test.tsx index d5c34fb7a9..9ec8e4f619 100644 --- a/src/app/kvm/components/KVMForms/AddVirsh/AddVirshFields/AddVirshFields.test.tsx +++ b/src/app/kvm/components/KVMForms/AddVirsh/AddVirshFields/AddVirshFields.test.tsx @@ -9,7 +9,7 @@ import { renderWithBrowserRouter, screen } from "@/testing/utils"; describe("AddVirshFields", () => { let state: RootState; - + const queryData = { zones: [factory.zone()] }; beforeEach(() => { state = factory.rootState({ config: factory.configState({ @@ -34,7 +34,6 @@ describe("AddVirshFields", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone()], }), }); }); @@ -62,6 +61,7 @@ describe("AddVirshFields", () => { renderWithBrowserRouter(, { state, + queryData, route: "/machines/chassis/add", }); diff --git a/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.test.tsx b/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.test.tsx index 28c8005010..d8caf53a9b 100644 --- a/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.test.tsx +++ b/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.test.tsx @@ -12,6 +12,9 @@ import * as factory from "@/testing/factories"; import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; const mockStore = configureStore(); +const queryData = { + zones: [factory.zone({ id: 3, name: "danger-zone" })], +}; describe("ComposeForm", () => { let state: RootState; @@ -58,7 +61,6 @@ describe("ComposeForm", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone({ id: 3, name: "danger-zone" })], }), }); }); @@ -67,7 +69,7 @@ describe("ComposeForm", () => { const store = mockStore(state); renderWithBrowserRouter( , - { route: "/kvm/1", store } + { route: "/kvm/1", store, queryData } ); const expectedActions = [ "FETCH_DOMAIN", @@ -87,7 +89,7 @@ describe("ComposeForm", () => { }); it("displays a spinner if data has not loaded", () => { - state.zone.genericActions.fetch = "idle"; + state.domain.loaded = false; renderWithBrowserRouter( , { route: "/kvm/1", state } @@ -139,7 +141,7 @@ describe("ComposeForm", () => { const store = mockStore(state); renderWithBrowserRouter( , - { route: "/kvm/1", store } + { route: "/kvm/1", store, queryData } ); await userEvent.clear(screen.getByRole("textbox", { name: "VM name" })); @@ -255,7 +257,7 @@ describe("ComposeForm", () => { const store = mockStore(state); renderWithBrowserRouter( , - { route: "/kvm/1", store } + { route: "/kvm/1", store, queryData } ); await userEvent.clear(screen.getByRole("textbox", { name: "VM name" })); diff --git a/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.tsx b/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.tsx index 5e6fdb2bfd..ceaf502042 100644 --- a/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.tsx +++ b/src/app/kvm/components/KVMForms/ComposeForm/ComposeForm.tsx @@ -14,6 +14,7 @@ import ComposeFormFields from "./ComposeFormFields"; import InterfacesTable from "./InterfacesTable"; import StorageTable from "./StorageTable"; +import { useZones } from "@/app/api/query/zones"; import ActionForm from "@/app/base/components/ActionForm"; import type { ClearSidePanelContent } from "@/app/base/types"; import { hostnameValidation, RANGE_REGEX } from "@/app/base/validation"; @@ -46,8 +47,6 @@ import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet } from "@/app/store/subnet/types"; import { vlanActions } from "@/app/store/vlan"; import vlanSelectors from "@/app/store/vlan/selectors"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; import { arrayFromRangesString, getRanges } from "@/app/utils"; export type Disk = { @@ -202,8 +201,7 @@ const ComposeForm = ({ clearSidePanelContent, hostId }: Props): JSX.Element => { const subnetsLoaded = useSelector(subnetSelectors.loaded); const vlans = useSelector(vlanSelectors.all); const vlansLoaded = useSelector(vlanSelectors.loaded); - const zones = useSelector(zoneSelectors.all); - const zonesLoaded = useSelector(zoneSelectors.loaded); + const zones = useZones(); const [machineName, setMachineName] = useState(""); const cleanup = useCallback(() => podActions.cleanup(), []); useActivePod(hostId); @@ -217,9 +215,7 @@ const ComposeForm = ({ clearSidePanelContent, hostId }: Props): JSX.Element => { dispatch(spaceActions.fetch()); dispatch(subnetActions.fetch()); dispatch(vlanActions.fetch()); - dispatch(zoneActions.fetch()); }, [dispatch, hostId]); - const loaded = domainsLoaded && fabricsLoaded && @@ -228,7 +224,7 @@ const ComposeForm = ({ clearSidePanelContent, hostId }: Props): JSX.Element => { spacesLoaded && subnetsLoaded && vlansLoaded && - zonesLoaded; + !zones.isPending; if (isPodDetails(pod) && loaded) { const powerType = powerTypes.find((type) => type.name === pod.type); @@ -456,7 +452,7 @@ const ComposeForm = ({ clearSidePanelContent, hostId }: Props): JSX.Element => { memory: defaults.memory, pinnedCores: "", pool: `${pools[0]?.id}` || "", - zone: `${zones[0]?.id}` || "", + zone: `${zones.data?.[0]?.id}` || "", }} modelName="machine" onCancel={clearSidePanelContent} diff --git a/src/app/kvm/components/KVMForms/ComposeForm/ComposeFormFields/ComposeFormFields.test.tsx b/src/app/kvm/components/KVMForms/ComposeForm/ComposeFormFields/ComposeFormFields.test.tsx index 96f2fd7f91..cb30be5e7f 100644 --- a/src/app/kvm/components/KVMForms/ComposeForm/ComposeFormFields/ComposeFormFields.test.tsx +++ b/src/app/kvm/components/KVMForms/ComposeForm/ComposeFormFields/ComposeFormFields.test.tsx @@ -418,10 +418,9 @@ describe("ComposeFormFields", () => { cores: factory.podResource({ free: 1 }), }); state.pod.items[0].cpu_over_commit_ratio = 1; - const store = mockStore(state); renderWithBrowserRouter( , - { route: "/kvm/1", store } + { route: "/kvm/1", state } ); // Switch to pinning cores diff --git a/src/app/kvm/components/KVMForms/KVMForms.test.tsx b/src/app/kvm/components/KVMForms/KVMForms.test.tsx index f3082d30da..6b0f7363ac 100644 --- a/src/app/kvm/components/KVMForms/KVMForms.test.tsx +++ b/src/app/kvm/components/KVMForms/KVMForms.test.tsx @@ -3,7 +3,7 @@ import KVMForms from "./KVMForms"; import { KVMSidePanelViews } from "@/app/kvm/constants"; import { MachineSidePanelViews } from "@/app/machines/constants"; import { PodType } from "@/app/store/pod/constants"; -import zoneSelectors from "@/app/store/zone/selectors"; +import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { getByTextContent, @@ -12,12 +12,12 @@ import { } from "@/testing/utils"; describe("KVMForms", () => { - let state = factory.rootState(); + let state: RootState; + const queryData = { + zones: [factory.zone({ id: 1 })], + }; beforeEach(() => { - // "loaded" doesn't exist on ZoneState type, so we have to mock the return value here - vi.spyOn(zoneSelectors, "loaded").mockReturnValue(true); - state = factory.rootState({ domain: factory.domainState({ loaded: true, @@ -57,16 +57,13 @@ describe("KVMForms", () => { vlan: factory.vlanState({ loaded: true, }), - zone: factory.zoneState({ - items: [factory.zone({ id: 1 })], - }), }); }); it("does not render if sidePanelContent is not defined", () => { renderWithBrowserRouter( , - { state } + { state, queryData } ); expect( screen.queryByTestId("kvm-action-form-wrapper") @@ -79,7 +76,7 @@ describe("KVMForms", () => { setSidePanelContent={vi.fn()} sidePanelContent={{ view: KVMSidePanelViews.ADD_LXD_HOST }} />, - { state } + { state, queryData } ); // Ensure AddLxd fields are shown expect(screen.getByText("Credentials")).toBeInTheDocument(); @@ -104,7 +101,7 @@ describe("KVMForms", () => { setSidePanelContent={vi.fn()} sidePanelContent={{ view: KVMSidePanelViews.ADD_VIRSH_HOST }} />, - { state } + { state, queryData } ); expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument(); @@ -130,7 +127,7 @@ describe("KVMForms", () => { extras: { hostId: 1 }, }} />, - { state } + { state, queryData } ); expect( @@ -160,7 +157,7 @@ describe("KVMForms", () => { extras: { hostId: 1 }, }} />, - { state } + { state, queryData } ); expect( @@ -189,7 +186,7 @@ describe("KVMForms", () => { extras: { clusterId: 1 }, }} />, - { state } + { state, queryData } ); expect( screen.getByText( @@ -217,7 +214,7 @@ describe("KVMForms", () => { extras: { hostIds: [1] }, }} />, - { state } + { state, queryData } ); expect( diff --git a/src/app/kvm/components/PoolColumn/PoolColumn.test.tsx b/src/app/kvm/components/PoolColumn/PoolColumn.test.tsx index 61d8ffab9c..d8a148a066 100644 --- a/src/app/kvm/components/PoolColumn/PoolColumn.test.tsx +++ b/src/app/kvm/components/PoolColumn/PoolColumn.test.tsx @@ -1,19 +1,23 @@ -import { render, screen } from "@testing-library/react"; -import { Provider } from "react-redux"; -import configureStore from "redux-mock-store"; +import { screen } from "@testing-library/react"; import PoolColumn from "./PoolColumn"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; - -const mockStore = configureStore(); +import { renderWithBrowserRouter } from "@/testing/utils"; describe("PoolColumn", () => { - let initialState: RootState; - + let state: RootState; + const queryData = { + zones: [ + factory.zone({ + id: 1, + name: "alone-zone", + }), + ], + }; beforeEach(() => { - initialState = factory.rootState({ + state = factory.rootState({ pod: factory.podState({ items: [ factory.pod({ @@ -31,27 +35,16 @@ describe("PoolColumn", () => { }), ], }), - zone: factory.zoneState({ - items: [ - factory.zone({ - id: 1, - name: "alone-zone", - }), - ], - }), }); }); it("can display the pod's resource pool and zone", () => { - const state = { ...initialState }; - const store = mockStore(state); - render( - - - + renderWithBrowserRouter( + , + { state, queryData } ); expect(screen.getByTestId("pool")).toHaveTextContent("swimming-pool"); expect(screen.getByTestId("zone")).toHaveTextContent("alone-zone"); diff --git a/src/app/kvm/components/PoolColumn/PoolColumn.tsx b/src/app/kvm/components/PoolColumn/PoolColumn.tsx index 2f3f01d7c9..acd38b5163 100644 --- a/src/app/kvm/components/PoolColumn/PoolColumn.tsx +++ b/src/app/kvm/components/PoolColumn/PoolColumn.tsx @@ -1,5 +1,6 @@ import { useSelector } from "react-redux"; +import { useZoneById } from "@/app/api/query/zones"; import DoubleRow from "@/app/base/components/DoubleRow"; import poolSelectors from "@/app/store/resourcepool/selectors"; import type { @@ -7,7 +8,6 @@ import type { ResourcePoolMeta, } from "@/app/store/resourcepool/types"; import type { RootState } from "@/app/store/root/types"; -import zoneSelectors from "@/app/store/zone/selectors"; import type { Zone, ZoneMeta } from "@/app/store/zone/types"; type Props = { @@ -19,9 +19,7 @@ const PoolColumn = ({ poolId, zoneId }: Props): JSX.Element | null => { const pool = useSelector((state: RootState) => poolSelectors.getById(state, poolId) ); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, zoneId) - ); + const { data: zone } = useZoneById(zoneId); return ( { "pod/fetch", "resourcepool/fetch", "vmcluster/fetch", - "zone/fetch", ]; const actualActions = store.getActions(); expect( diff --git a/src/app/kvm/views/KVMList/KVMList.tsx b/src/app/kvm/views/KVMList/KVMList.tsx index d4632793f1..ac05f8442b 100644 --- a/src/app/kvm/views/KVMList/KVMList.tsx +++ b/src/app/kvm/views/KVMList/KVMList.tsx @@ -20,7 +20,6 @@ import podSelectors from "@/app/store/pod/selectors"; import { resourcePoolActions } from "@/app/store/resourcepool"; import { vmClusterActions } from "@/app/store/vmcluster"; import vmclusterSelectors from "@/app/store/vmcluster/selectors"; -import { zoneActions } from "@/app/store/zone"; export enum Label { Title = "KVM list", @@ -46,7 +45,6 @@ const KVMList = (): JSX.Element => { podActions.fetch, resourcePoolActions.fetch, vmClusterActions.fetch, - zoneActions.fetch, ]); // Redirect to the appropriate tab when arriving at /kvm. diff --git a/src/app/kvm/views/KVMList/LxdTable/LxdKVMHostTable/LxdKVMHostTable.tsx b/src/app/kvm/views/KVMList/LxdTable/LxdKVMHostTable/LxdKVMHostTable.tsx index b0d08d9163..811570d17f 100644 --- a/src/app/kvm/views/KVMList/LxdTable/LxdKVMHostTable/LxdKVMHostTable.tsx +++ b/src/app/kvm/views/KVMList/LxdTable/LxdKVMHostTable/LxdKVMHostTable.tsx @@ -1,7 +1,7 @@ import { Icon, MainTable } from "@canonical/react-components"; import pluralize from "pluralize"; -import { useSelector } from "react-redux"; +import { useZones } from "@/app/api/query/zones"; import DoubleRow from "@/app/base/components/DoubleRow"; import TableHeader from "@/app/base/components/TableHeader"; import { useTableSort } from "@/app/base/hooks"; @@ -17,7 +17,6 @@ import VMsColumn from "@/app/kvm/components/VMsColumn"; import type { KVMResource, KVMStoragePoolResources } from "@/app/kvm/types"; import type { Pod, PodMeta } from "@/app/store/pod/types"; import type { VMCluster, VMClusterMeta } from "@/app/store/vmcluster/types"; -import zoneSelectors from "@/app/store/zone/selectors"; import type { Zone } from "@/app/store/zone/types"; import { isComparable } from "@/app/utils"; @@ -152,7 +151,7 @@ const generateRows = (rows: LxdKVMHostTableRow[]) => }); const LxdKVMHostTable = ({ rows }: Props): JSX.Element => { - const zones = useSelector(zoneSelectors.all); + const zones = useZones(); const { currentSort, sortRows, updateSort } = useTableSort< LxdKVMHostTableRow, SortKey, @@ -161,7 +160,7 @@ const LxdKVMHostTable = ({ rows }: Props): JSX.Element => { key: "name", direction: SortDirection.DESCENDING, }); - const sortedRows = sortRows(rows, zones); + const sortedRows = sortRows(rows, zones.data); return ( { let state: RootState; - + let pods = [ + factory.pod({ pool: 1, zone: 1 }), + factory.pod({ pool: 2, zone: 2 }), + ]; + const queryData = { + zones: [ + factory.zone({ id: pods[0].zone }), + factory.zone({ id: pods[1].zone }), + ], + }; beforeEach(() => { - const pods = [ + pods = [ factory.pod({ pool: 1, zone: 1 }), factory.pod({ pool: 2, zone: 2 }), ]; @@ -21,17 +30,15 @@ describe("VirshTable", () => { factory.resourcePool({ id: pods[1].pool }), ], }), - zone: factory.zoneState({ - items: [ - factory.zone({ id: pods[0].zone }), - factory.zone({ id: pods[1].zone }), - ], - }), }); }); it("shows pods sorted by descending name by default", () => { - renderWithBrowserRouter(, { route: "/kvm", state }); + renderWithBrowserRouter(, { + route: "/kvm", + state, + queryData, + }); expect( screen.getByRole("button", { name: "Name (descending)" }) ).toBeInTheDocument(); @@ -40,7 +47,11 @@ describe("VirshTable", () => { it("can sort by parameters of the pods themselves", async () => { state.pod.items[0].resources.vm_count.tracked = 1; state.pod.items[1].resources.vm_count.tracked = 2; - renderWithBrowserRouter(, { route: "/kvm", state }); + renderWithBrowserRouter(, { + route: "/kvm", + state, + queryData, + }); const [firstPod, secondPod] = [state.pod.items[0], state.pod.items[1]]; const getName = (rowNumber: number) => screen.getAllByTestId("name")[rowNumber].textContent; @@ -86,7 +97,11 @@ describe("VirshTable", () => { const [firstPod, secondPod] = [state.pod.items[0], state.pod.items[1]]; firstPod.pool = pools[0].id; secondPod.pool = pools[1].id; - renderWithBrowserRouter(, { route: "/kvm", state }); + renderWithBrowserRouter(, { + route: "/kvm", + state, + queryData, + }); const getName = (rowNumber: number) => screen.getAllByTestId("name")[rowNumber].textContent; @@ -112,7 +127,11 @@ describe("VirshTable", () => { it("displays a message when empty", () => { state.pod.items = []; - renderWithBrowserRouter(, { route: "/kvm", state }); + renderWithBrowserRouter(, { + route: "/kvm", + state, + queryData, + }); expect(screen.getByText("No pods available.")).toBeInTheDocument(); }); diff --git a/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.test.tsx b/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.test.tsx index 75c27174d7..b240f67d93 100644 --- a/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.test.tsx +++ b/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.test.tsx @@ -1,22 +1,19 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import LXDClusterDetailsHeader from "./LXDClusterDetailsHeader"; import urls from "@/app/base/urls"; import { KVMSidePanelViews } from "@/app/kvm/constants"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen } from "@/testing/utils"; - -const mockStore = configureStore(); +import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; describe("LXDClusterDetailsHeader", () => { let state: RootState; + const zone = factory.zone({ id: 111, name: "danger" }); + const queryData = { + zones: [zone], + }; beforeEach(() => { - const zone = factory.zone({ id: 111, name: "danger" }); const cluster = factory.vmCluster({ availability_zone: zone.id, id: 1, @@ -27,54 +24,24 @@ describe("LXDClusterDetailsHeader", () => { vmcluster: factory.vmClusterState({ items: [cluster], }), - zone: factory.zoneState({ - items: [zone], - }), }); }); it("displays a spinner if cluster hasn't loaded", () => { state.vmcluster.items = []; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: urls.kvm.lxd.cluster.index({ clusterId: 1 }), state, queryData } ); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); it("displays the cluster member count", () => { state.vmcluster.items[0].hosts = [factory.vmHost(), factory.vmHost()]; - const store = mockStore(state); - render( - - - - - + + renderWithBrowserRouter( + , + { route: urls.kvm.lxd.cluster.index({ clusterId: 1 }), state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[0]).toHaveTextContent( @@ -88,23 +55,9 @@ describe("LXDClusterDetailsHeader", () => { factory.virtualMachine(), factory.virtualMachine(), ]; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: urls.kvm.lxd.cluster.index({ clusterId: 1 }), state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[1]).toHaveTextContent( @@ -113,23 +66,9 @@ describe("LXDClusterDetailsHeader", () => { }); it("displays the cluster's zone's name", () => { - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: urls.kvm.lxd.cluster.index({ clusterId: 1 }), state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[2]).toHaveTextContent( @@ -138,23 +77,9 @@ describe("LXDClusterDetailsHeader", () => { }); it("displays the cluster's project", () => { - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: urls.kvm.lxd.cluster.index({ clusterId: 1 }), state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[3]).toHaveTextContent( @@ -166,23 +91,12 @@ describe("LXDClusterDetailsHeader", () => { const hosts = [factory.vmHost(), factory.vmHost()]; state.vmcluster.items[0].hosts = hosts; const setSidePanelContent = vi.fn(); - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: urls.kvm.lxd.cluster.index({ clusterId: 1 }), state, queryData } ); await userEvent.click( diff --git a/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.tsx b/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.tsx index 33f3f87116..fdf3f685c4 100644 --- a/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.tsx +++ b/src/app/kvm/views/LXDClusterDetails/LXDClusterDetailsHeader/LXDClusterDetailsHeader.tsx @@ -5,7 +5,7 @@ import pluralize from "pluralize"; import { useSelector } from "react-redux"; import { useLocation, Link } from "react-router-dom"; -import { useFetchActions } from "@/app/base/hooks"; +import { useZoneById } from "@/app/api/query/zones"; import urls from "@/app/base/urls"; import KVMDetailsHeader from "@/app/kvm/components/KVMDetailsHeader"; import { KVMSidePanelViews } from "@/app/kvm/constants"; @@ -13,8 +13,6 @@ import type { KVMSetSidePanelContent } from "@/app/kvm/types"; import type { RootState } from "@/app/store/root/types"; import vmClusterSelectors from "@/app/store/vmcluster/selectors"; import type { VMCluster } from "@/app/store/vmcluster/types"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { clusterId: VMCluster["id"]; @@ -28,14 +26,10 @@ const LXDClusterDetailsHeader = ({ const cluster = useSelector((state: RootState) => vmClusterSelectors.getById(state, clusterId) ); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, cluster?.availability_zone) - ); + const zone = useZoneById(cluster?.availability_zone); const location = useLocation(); const canRefresh = !!cluster?.hosts.length; - useFetchActions([zoneActions.fetch]); - let title: ReactNode = ; if (cluster) { title = cluster.name; @@ -121,7 +115,7 @@ const LXDClusterDetailsHeader = ({ }, { title: "AZ:", - subtitle: zone?.name || , + subtitle: zone?.data?.name || , }, { title: "LXD project:", diff --git a/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.test.tsx b/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.test.tsx index 3295e7b573..a1a339e631 100644 --- a/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.test.tsx +++ b/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.test.tsx @@ -1,16 +1,14 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import LXDSingleDetailsHeader from "./LXDSingleDetailsHeader"; import { KVMSidePanelViews } from "@/app/kvm/constants"; import { PodType } from "@/app/store/pod/constants"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen } from "@/testing/utils"; +import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; -const mockStore = configureStore(); +const queryData = { + zones: [factory.zone({ id: 101, name: "danger" })], +}; describe("LXDSingleDetailsHeader", () => { let state: RootState; @@ -40,13 +38,9 @@ describe("LXDSingleDetailsHeader", () => { it("displays a spinner if pod hasn't loaded", () => { state.pod.items = []; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: "/kvm/1/resources", state, queryData } ); expect(screen.getByText("Loading...")).toBeInTheDocument(); @@ -56,15 +50,9 @@ describe("LXDSingleDetailsHeader", () => { state.pod.items[0].power_parameters = factory.podPowerParameters({ project: "Manhattan", }); - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: "/kvm/1/resources", state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[3]).toHaveTextContent( @@ -76,15 +64,9 @@ describe("LXDSingleDetailsHeader", () => { state.pod.items[0].resources = factory.podResources({ vm_count: factory.podVmCount({ tracked: 5 }), }); - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: "/kvm/1/resources", state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[1]).toHaveTextContent( @@ -93,17 +75,10 @@ describe("LXDSingleDetailsHeader", () => { }); it("displays the pod's zone's name", () => { - state.zone.items = [factory.zone({ id: 101, name: "danger" })]; state.pod.items[0].zone = 101; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { route: "/kvm/1/resources", state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[2]).toHaveTextContent( @@ -112,23 +87,16 @@ describe("LXDSingleDetailsHeader", () => { }); it("can open the refresh host form", async () => { - state.zone.items = [factory.zone({ id: 101, name: "danger" })]; state.pod.items[0].zone = 101; const setSidePanelContent = vi.fn(); - const store = mockStore(state); - render( - - - - - + const queryData = { zones: [factory.zone({ id: 101, name: "danger" })] }; + renderWithBrowserRouter( + , + { route: "/kvm/1/resources", state, queryData } ); - await userEvent.click(screen.getByRole("button", { name: "Refresh host" })); expect(setSidePanelContent).toHaveBeenCalledWith({ diff --git a/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.tsx b/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.tsx index bb4636f9e7..fcd7d84e28 100644 --- a/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.tsx +++ b/src/app/kvm/views/LXDSingleDetails/LXDSingleDetailsHeader/LXDSingleDetailsHeader.tsx @@ -4,6 +4,7 @@ import { Button, Icon, Spinner } from "@canonical/react-components"; import { useSelector } from "react-redux"; import { useLocation, Link } from "react-router-dom"; +import { useZoneById } from "@/app/api/query/zones"; import { useFetchActions } from "@/app/base/hooks"; import urls from "@/app/base/urls"; import KVMDetailsHeader from "@/app/kvm/components/KVMDetailsHeader"; @@ -13,8 +14,6 @@ import { podActions } from "@/app/store/pod"; import podSelectors from "@/app/store/pod/selectors"; import type { Pod } from "@/app/store/pod/types"; import type { RootState } from "@/app/store/root/types"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { id: Pod["id"]; @@ -29,11 +28,9 @@ const LXDSingleDetailsHeader = ({ const pod = useSelector((state: RootState) => podSelectors.getById(state, id) ); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, pod?.zone) - ); + const zone = useZoneById(pod?.zone); - useFetchActions([podActions.fetch, zoneActions.fetch]); + useFetchActions([podActions.fetch]); let title: ReactNode = ; if (pod) { @@ -101,7 +98,7 @@ const LXDSingleDetailsHeader = ({ }, { title: "AZ:", - subtitle: zone?.name || , + subtitle: zone?.data?.name || , }, { title: "LXD project:", diff --git a/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.test.tsx b/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.test.tsx index 671197cfa5..d64969c516 100644 --- a/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.test.tsx +++ b/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.test.tsx @@ -1,14 +1,10 @@ -import { render, screen } from "@testing-library/react"; -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; +import { screen } from "@testing-library/react"; import LXDSingleSettings from "./LXDSingleSettings"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; - -const mockStore = configureStore(); +import { renderWithBrowserRouter } from "@/testing/utils"; describe("LXDSingleSettings", () => { let state: RootState; @@ -30,13 +26,9 @@ describe("LXDSingleSettings", () => { }); it("fetches the necessary data on load", () => { - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { state } ); const expectedActionTypes = [ "resourcepool/fetch", @@ -55,13 +47,9 @@ describe("LXDSingleSettings", () => { it("displays a spinner if data has not loaded", () => { state.resourcepool.loaded = false; - const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + , + { state } ); expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); diff --git a/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.tsx b/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.tsx index 02c2d71652..e367f4d692 100644 --- a/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.tsx +++ b/src/app/kvm/views/LXDSingleDetails/LXDSingleSettings/LXDSingleSettings.tsx @@ -4,6 +4,7 @@ import { useSelector } from "react-redux"; import AuthenticationCard from "./AuthenticationCard"; import DangerZoneCard from "./DangerZoneCard"; +import { useZones } from "@/app/api/query/zones"; import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; import KVMConfigurationCard from "@/app/kvm/components/KVMConfigurationCard"; import LXDHostToolbar from "@/app/kvm/components/LXDHostToolbar"; @@ -16,8 +17,6 @@ import resourcePoolSelectors from "@/app/store/resourcepool/selectors"; import type { RootState } from "@/app/store/root/types"; import { tagActions } from "@/app/store/tag"; import tagSelectors from "@/app/store/tag/selectors"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { id: Pod["id"]; @@ -37,15 +36,11 @@ const LXDSingleSettings = ({ ); const resourcePoolsLoaded = useSelector(resourcePoolSelectors.loaded); const tagsLoaded = useSelector(tagSelectors.loaded); - const zonesLoaded = useSelector(zoneSelectors.loaded); - const loaded = resourcePoolsLoaded && tagsLoaded && zonesLoaded; + const zones = useZones(); + const loaded = resourcePoolsLoaded && tagsLoaded && !zones.isPending; useWindowTitle(`LXD ${`${pod?.name} ` || ""} settings`); - useFetchActions([ - resourcePoolActions.fetch, - tagActions.fetch, - zoneActions.fetch, - ]); + useFetchActions([resourcePoolActions.fetch, tagActions.fetch]); if (!isPodDetails(pod) || !loaded) { return ; diff --git a/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.test.tsx b/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.test.tsx index 79d1341d38..ebf56fc700 100644 --- a/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.test.tsx +++ b/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.test.tsx @@ -7,6 +7,7 @@ import { renderWithBrowserRouter, screen } from "@/testing/utils"; describe("VirshDetailsHeader", () => { let state: RootState; + const route = "/kvm/1/resources"; beforeEach(() => { state = factory.rootState({ @@ -46,7 +47,7 @@ describe("VirshDetailsHeader", () => { }); renderWithBrowserRouter( , - { route: "/kvm/1/resources", state } + { route, state } ); expect(screen.getAllByTestId("block-subtitle")[0]).toHaveTextContent( "qemu+ssh://ubuntu@192.168.1.1/system" @@ -59,7 +60,7 @@ describe("VirshDetailsHeader", () => { }); renderWithBrowserRouter( , - { route: "/kvm/1/resources", state } + { route, state } ); expect(screen.getAllByTestId("block-subtitle")[1]).toHaveTextContent( "5 available" @@ -67,11 +68,11 @@ describe("VirshDetailsHeader", () => { }); it("displays the pod zone name", () => { - state.zone.items = [factory.zone({ id: 101, name: "danger" })]; + const queryData = { zones: [factory.zone({ id: 101, name: "danger" })] }; state.pod.items[0].zone = 101; renderWithBrowserRouter( , - { route: "/kvm/1/resources", state } + { route, state, queryData } ); expect(screen.getAllByTestId("block-subtitle")[2]).toHaveTextContent( "danger" diff --git a/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.tsx b/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.tsx index 082b4abae7..e7f7d35097 100644 --- a/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.tsx +++ b/src/app/kvm/views/VirshDetails/VirshDetailsHeader/VirshDetailsHeader.tsx @@ -6,6 +6,7 @@ import { useLocation, Link } from "react-router-dom"; import VirshDetailsActionMenu from "./VirshDetailsActionMenu"; +import { useZoneById } from "@/app/api/query/zones"; import { useFetchActions } from "@/app/base/hooks"; import urls from "@/app/base/urls"; import KVMDetailsHeader from "@/app/kvm/components/KVMDetailsHeader"; @@ -14,8 +15,6 @@ import { podActions } from "@/app/store/pod"; import podSelectors from "@/app/store/pod/selectors"; import type { Pod } from "@/app/store/pod/types"; import type { RootState } from "@/app/store/root/types"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { id: Pod["id"]; @@ -30,11 +29,9 @@ const VirshDetailsHeader = ({ const pod = useSelector((state: RootState) => podSelectors.getById(state, id) ); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, pod?.zone) - ); + const zone = useZoneById(pod?.zone); - useFetchActions([podActions.fetch, zoneActions.fetch]); + useFetchActions([podActions.fetch]); let title: ReactNode = ; if (pod) { @@ -88,7 +85,7 @@ const VirshDetailsHeader = ({ }, { title: "AZ:", - subtitle: zone?.name || , + subtitle: zone?.data?.name || , }, ] : [] diff --git a/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.test.tsx b/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.test.tsx index c3b8cca998..9e54330243 100644 --- a/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.test.tsx +++ b/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.test.tsx @@ -1,14 +1,8 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import VirshSettings from "./VirshSettings"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { render, screen } from "@/testing/utils"; - -const mockStore = configureStore(); +import { renderWithBrowserRouter, screen } from "@/testing/utils"; describe("VirshSettings", () => { let state: RootState; @@ -30,14 +24,9 @@ describe("VirshSettings", () => { }); it("fetches the necessary data on load", () => { - const store = mockStore(state); - render( - - - - - - ); + const { store } = renderWithBrowserRouter(, { + state, + }); const expectedActionTypes = [ "resourcepool/fetch", "tag/fetch", @@ -55,14 +44,8 @@ describe("VirshSettings", () => { it("displays a spinner if data has not loaded", () => { state.resourcepool.loaded = false; - const store = mockStore(state); - render( - - - - - - ); + + renderWithBrowserRouter(, { state }); expect(screen.getByText(/Loading/)).toBeInTheDocument(); }); }); diff --git a/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.tsx b/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.tsx index a135713a58..2de7af502f 100644 --- a/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.tsx +++ b/src/app/kvm/views/VirshDetails/VirshSettings/VirshSettings.tsx @@ -1,6 +1,7 @@ import { Spinner } from "@canonical/react-components"; import { useSelector } from "react-redux"; +import { useZones } from "@/app/api/query/zones"; import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; import KVMConfigurationCard from "@/app/kvm/components/KVMConfigurationCard"; import podSelectors from "@/app/store/pod/selectors"; @@ -11,8 +12,6 @@ import resourcePoolSelectors from "@/app/store/resourcepool/selectors"; import type { RootState } from "@/app/store/root/types"; import { tagActions } from "@/app/store/tag"; import tagSelectors from "@/app/store/tag/selectors"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { id: Pod["id"]; @@ -28,15 +27,11 @@ const VirshSettings = ({ id }: Props): JSX.Element | null => { ); const resourcePoolsLoaded = useSelector(resourcePoolSelectors.loaded); const tagsLoaded = useSelector(tagSelectors.loaded); - const zonesLoaded = useSelector(zoneSelectors.loaded); - const loaded = resourcePoolsLoaded && tagsLoaded && zonesLoaded; + const zones = useZones(); + const loaded = resourcePoolsLoaded && tagsLoaded && !zones.isPending; useWindowTitle(`Virsh ${`${pod?.name} ` || ""} settings`); - useFetchActions([ - resourcePoolActions.fetch, - tagActions.fetch, - zoneActions.fetch, - ]); + useFetchActions([resourcePoolActions.fetch, tagActions.fetch]); if (!isPodDetails(pod) || !loaded) { return ; diff --git a/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.test.tsx b/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.test.tsx index fb9573c768..f2a7d18102 100644 --- a/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.test.tsx +++ b/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.test.tsx @@ -1,18 +1,18 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import AddMachineForm from "./AddMachineForm"; import { PowerFieldType } from "@/app/store/general/types"; import { machineActions } from "@/app/store/machine"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; - -const mockStore = configureStore(); +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; let state: RootState; +const queryData = { zones: [factory.zone({ id: 1, name: "twilight" })] }; beforeEach(() => { state = factory.rootState({ @@ -72,26 +72,18 @@ beforeEach(() => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [ - factory.zone({ - name: "twilight", - }), - ], }), }); }); it("fetches the necessary data on load if not already loaded", () => { - state.resourcepool.loaded = false; - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + route: "/machines/add", + } ); const expectedActions = [ "FETCH_DOMAIN", @@ -110,55 +102,46 @@ it("fetches the necessary data on load if not already loaded", () => { it("displays a spinner if data has not loaded", () => { state.resourcepool.loaded = false; - const store = mockStore(state); - render( - - - - - - ); - + renderWithBrowserRouter(, { + state, + queryData, + route: "/machines/add", + }); expect(screen.getByTestId("loading")).toBeInTheDocument(); }); it("enables submit when a power type with no fields is chosen", async () => { - const store = mockStore(state); - render( - - - - - - ); - + renderWithBrowserRouter(, { + state, + queryData, + route: "/machines/add", + }); // Choose the "manual" power type which has no power fields, and fill in other // required fields. + await waitFor(() => { + expect(screen.queryByTestId(/Loading/)).not.toBeInTheDocument(); + }); await userEvent.selectOptions( - screen.getByRole("combobox", { name: "Power type" }), + await screen.findByRole("combobox", { name: "Power type" }), "manual" ); await userEvent.type( screen.getByRole("textbox", { name: "MAC address" }), "11:11:11:11:11:11" ); - expect(screen.getByRole("button", { name: "Save machine" })).toBeEnabled(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Save machine" })).toBeEnabled() + ); }); it("can handle saving a machine", async () => { - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + route: "/machines/add", + } ); await userEvent.type( @@ -215,15 +198,13 @@ it("can handle saving a machine", async () => { }); it("correctly trims power parameters before dispatching action", async () => { - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + route: "/machines/add", + } ); // Choose initial power type and fill in fields. @@ -272,15 +253,13 @@ it("correctly trims power parameters before dispatching action", async () => { }); it("correctly filters empty extra mac fields", async () => { - const store = mockStore(state); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { + state, + queryData, + route: "/machines/add", + } ); // Submit the form with two extra macs, where one is an empty string diff --git a/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.tsx b/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.tsx index 8ea5b4edb0..34450d1a7a 100644 --- a/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.tsx +++ b/src/app/machines/components/MachineForms/AddMachine/AddMachineForm/AddMachineForm.tsx @@ -8,6 +8,7 @@ import * as Yup from "yup"; import AddMachineFormFields from "../AddMachineFormFields"; import type { AddMachineValues } from "../types"; +import { useZones } from "@/app/api/query/zones"; import FormikForm from "@/app/base/components/FormikForm"; import docsUrls from "@/app/base/docsUrls"; import { useFetchActions, useAddMessage } from "@/app/base/hooks"; @@ -33,8 +34,6 @@ import { machineActions } from "@/app/store/machine"; import machineSelectors from "@/app/store/machine/selectors"; import { resourcePoolActions } from "@/app/store/resourcepool"; import resourcePoolSelectors from "@/app/store/resourcepool/selectors"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { clearSidePanelContent: ClearSidePanelContent; @@ -60,8 +59,7 @@ export const AddMachineForm = ({ const powerTypesLoaded = useSelector(powerTypesSelectors.loaded); const resourcePools = useSelector(resourcePoolSelectors.all); const resourcePoolsLoaded = useSelector(resourcePoolSelectors.loaded); - const zones = useSelector(zoneSelectors.all); - const zonesLoaded = useSelector(zoneSelectors.loaded); + const zones = useZones(); const [powerType, setPowerType] = useState(null); const [secondarySubmit, setSecondarySubmit] = useState(false); @@ -75,7 +73,6 @@ export const AddMachineForm = ({ generalActions.fetchHweKernels, generalActions.fetchPowerTypes, resourcePoolActions.fetch, - zoneActions.fetch, ]); useAddMessage( @@ -115,7 +112,7 @@ export const AddMachineForm = ({ hweKernelsLoaded && powerTypesLoaded && resourcePoolsLoaded && - zonesLoaded; + !zones.isPending; return ( <> @@ -145,7 +142,7 @@ export const AddMachineForm = ({ power_parameters: initialPowerParameters, power_type: "", pxe_mac: "", - zone: (zones.length && zones[0].name) || "", + zone: (zones?.data?.length && zones?.data[0].name) || "", }} onCancel={clearSidePanelContent} onSaveAnalytics={{ diff --git a/src/app/machines/components/MachineForms/AddMachine/AddMachineFormFields/AddMachineFormFields.test.tsx b/src/app/machines/components/MachineForms/AddMachine/AddMachineFormFields/AddMachineFormFields.test.tsx index 06c5805279..7435fca6df 100644 --- a/src/app/machines/components/MachineForms/AddMachine/AddMachineFormFields/AddMachineFormFields.test.tsx +++ b/src/app/machines/components/MachineForms/AddMachine/AddMachineFormFields/AddMachineFormFields.test.tsx @@ -55,7 +55,6 @@ describe("AddMachineFormFields", () => { }), zone: factory.zoneState({ genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [factory.zone()], }), }); }); diff --git a/src/app/machines/views/MachineDetails/MachineConfiguration/MachineForm/MachineForm.test.tsx b/src/app/machines/views/MachineDetails/MachineConfiguration/MachineForm/MachineForm.test.tsx index cae1024013..6df1d169eb 100644 --- a/src/app/machines/views/MachineDetails/MachineConfiguration/MachineForm/MachineForm.test.tsx +++ b/src/app/machines/views/MachineDetails/MachineConfiguration/MachineForm/MachineForm.test.tsx @@ -1,19 +1,23 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; import configureStore from "redux-mock-store"; import MachineForm from "./MachineForm"; import { Labels } from "@/app/base/components/EditableSection"; import { machineActions } from "@/app/store/machine"; -import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; const mockStore = configureStore(); - describe("MachineForm", () => { - let state: RootState; + let state: ReturnType; + const queryData = { + zones: [factory.zone()], + }; beforeEach(() => { state = factory.rootState({ @@ -38,14 +42,11 @@ describe("MachineForm", () => { it("is not editable if machine does not have edit permission", () => { state.machine.items[0].permissions = []; - const store = mockStore(state); - render( - - - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + route: "/machine/abc123", + }); expect( screen.queryByRole("button", { name: Labels.EditButton }) @@ -54,14 +55,11 @@ describe("MachineForm", () => { it("is editable if machine has edit permission", () => { state.machine.items[0].permissions = ["edit"]; - const store = mockStore(state); - render( - - - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + route: "/machine/abc123", + }); expect( screen.getAllByRole("button", { name: Labels.EditButton }).length @@ -69,14 +67,11 @@ describe("MachineForm", () => { }); it("renders read-only text fields until edit button is pressed", async () => { - const store = mockStore(state); - render( - - - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + route: "/machine/abc123", + }); expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); @@ -95,13 +90,12 @@ describe("MachineForm", () => { }); state.machine.items = [machine]; const store = mockStore(state); - render( - - - - - - ); + + renderWithBrowserRouter(, { + store, + queryData, + route: "/machine/abc123", + }); await userEvent.click( screen.getAllByRole("button", { name: Labels.EditButton })[0] diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx index cc4ebbb653..95d56047ea 100644 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx @@ -25,7 +25,18 @@ describe("MachineListTable", () => { let state: RootState; let machines: Machine[] = []; let groups: MachineStateListGroup[] = []; - + const queryData = { + zones: [ + factory.zone({ + id: 0, + name: "default", + }), + factory.zone({ + id: 1, + name: "Backup", + }), + ], + }; beforeEach(() => { machines = [ factory.machine({ @@ -191,18 +202,6 @@ describe("MachineListTable", () => { }), ], }), - zone: factory.zoneState({ - items: [ - factory.zone({ - id: 0, - name: "default", - }), - factory.zone({ - id: 1, - name: "Backup", - }), - ], - }), }); }); @@ -231,7 +230,7 @@ describe("MachineListTable", () => { sortKey={null} totalPages={1} />, - { state } + { state, queryData } ); expect( within( diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx index b980426d86..8d5204c925 100644 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx @@ -31,7 +31,6 @@ import { useMachineSelectedCount } from "@/app/store/machine/utils/hooks"; import { resourcePoolActions } from "@/app/store/resourcepool"; import { tagActions } from "@/app/store/tag"; import { userActions } from "@/app/store/user"; -import { zoneActions } from "@/app/store/zone"; import { generateEmptyStateMsg, getTableStatus } from "@/app/utils"; export enum Label { @@ -102,7 +101,6 @@ export const MachineListTable = ({ resourcePoolActions.fetch, tagActions.fetch, userActions.fetch, - zoneActions.fetch, ]); const toggleHandler = useCallback( diff --git a/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.test.tsx index b5fd183880..2f632f586e 100644 --- a/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.test.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.test.tsx @@ -1,5 +1,3 @@ -import configureStore from "redux-mock-store"; - import { ZoneColumn } from "./ZoneColumn"; import type { RootState } from "@/app/store/root/types"; @@ -12,10 +10,20 @@ import { waitFor, } from "@/testing/utils"; -const mockStore = configureStore(); - describe("ZoneColumn", () => { let state: RootState; + const queryData = { + zones: [ + factory.zone({ + id: 0, + name: "default", + }), + factory.zone({ + id: 1, + name: "Backup", + }), + ], + }; beforeEach(() => { state = factory.rootState({ machine: factory.machineState({ @@ -29,18 +37,6 @@ describe("ZoneColumn", () => { }), ], }), - zone: factory.zoneState({ - items: [ - factory.zone({ - id: 0, - name: "default", - }), - factory.zone({ - id: 1, - name: "Backup", - }), - ], - }), }); }); @@ -49,7 +45,7 @@ describe("ZoneColumn", () => { renderWithBrowserRouter( , - { route: "/machines", state } + { route: "/machines", state, queryData } ); expect(screen.getByTestId("zone")).toHaveTextContent("zone-one"); }); @@ -59,7 +55,7 @@ describe("ZoneColumn", () => { renderWithBrowserRouter( , - { route: "/machines", state } + { route: "/machines", state, queryData } ); expect(screen.getByTestId("spaces")).toHaveTextContent("space1"); }); @@ -69,7 +65,7 @@ describe("ZoneColumn", () => { renderWithBrowserRouter( , - { route: "/machines", state } + { route: "/machines", state, queryData } ); expect(screen.getByTestId("spaces")).toHaveTextContent("2 spaces"); }); @@ -79,7 +75,7 @@ describe("ZoneColumn", () => { renderWithBrowserRouter( , - { route: "/machines", state } + { route: "/machines", state, queryData } ); await userEvent.hover(screen.getByTestId("spaces")); @@ -95,7 +91,7 @@ describe("ZoneColumn", () => { renderWithBrowserRouter( , - { route: "/machines", state } + { route: "/machines", state, queryData } ); await userEvent.click(screen.getByRole("button", { name: "Change AZ:" })); @@ -105,14 +101,14 @@ describe("ZoneColumn", () => { }); it("can change zones", async () => { - const store = mockStore(state); - - renderWithBrowserRouter( + const { store } = renderWithBrowserRouter( , - { route: "/machines", store } + { route: "/machines", state, queryData } ); - await userEvent.click(screen.getByRole("button", { name: "Change AZ:" })); - await userEvent.click(screen.getByTestId("change-zone-link")); + await userEvent.click( + await screen.findByRole("button", { name: "Change AZ:" }) + ); + await userEvent.click(await screen.findByTestId("change-zone-link")); expect( store.getActions().find((action) => action.type === "machine/setZone") @@ -137,10 +133,10 @@ describe("ZoneColumn", () => { it("shows a spinner when changing zones", async () => { renderWithBrowserRouter( , - { route: "/machines", state } + { route: "/machines", state, queryData } ); await userEvent.click(screen.getByRole("button", { name: "Change AZ:" })); - await userEvent.click(screen.getByTestId("change-zone-link")); + await userEvent.click(await screen.findByTestId("change-zone-link")); expect(screen.getByText(/Loading/i)).toBeInTheDocument(); }); diff --git a/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.tsx index c334f5a8a7..6d759372f7 100644 --- a/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/ZoneColumn/ZoneColumn.tsx @@ -4,6 +4,7 @@ import { Spinner, Tooltip } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; import { Link } from "react-router-dom"; +import { useZones } from "@/app/api/query/zones"; import DoubleRow from "@/app/base/components/DoubleRow"; import urls from "@/app/base/urls"; import { useToggleMenu } from "@/app/machines/hooks"; @@ -13,7 +14,6 @@ import machineSelectors from "@/app/store/machine/selectors"; import type { Machine, MachineMeta } from "@/app/store/machine/types"; import type { RootState } from "@/app/store/root/types"; import { NodeActions } from "@/app/store/types/node"; -import zoneSelectors from "@/app/store/zone/selectors"; import type { Zone, ZoneMeta } from "@/app/store/zone/types"; type Props = { @@ -46,13 +46,15 @@ export const ZoneColumn = ({ const machine = useSelector((state: RootState) => machineSelectors.getById(state, systemId) ); - const zones = useSelector(zoneSelectors.all); + const zones = useZones(); const toggleMenu = useToggleMenu(onToggleMenu || null); let zoneLinks; - const machineZones = zones.filter((zone) => zone.id !== machine?.zone.id); + const machineZones = zones.data?.filter( + (zone) => zone.id !== machine?.zone.id + ); if (machine?.actions.includes(NodeActions.SET_ZONE)) { - if (machineZones.length !== 0) { - zoneLinks = machineZones.map((zone) => ({ + if (machineZones?.length !== 0) { + zoneLinks = machineZones?.map((zone) => ({ children: zone.name, "data-testid": "change-zone-link", onClick: () => { diff --git a/src/app/networkDiscovery/views/DiscoveryAddForm/DiscoveryAddForm.test.tsx b/src/app/networkDiscovery/views/DiscoveryAddForm/DiscoveryAddForm.test.tsx index b705866804..9a6a5e0255 100644 --- a/src/app/networkDiscovery/views/DiscoveryAddForm/DiscoveryAddForm.test.tsx +++ b/src/app/networkDiscovery/views/DiscoveryAddForm/DiscoveryAddForm.test.tsx @@ -165,7 +165,9 @@ describe("DiscoveryAddForm", () => { // Change the device state to included the errors (as if it has changed via an API response). state.device.errors = { name: error }; // Rerender the form to simulate the state change. - rerender(); + rerender(, { + state, + }); expect( screen.getByRole("textbox", { name: `${FormFieldLabels.Hostname}`, diff --git a/src/app/store/domain/slice.ts b/src/app/store/domain/slice.ts index e3c436baed..2322377470 100644 --- a/src/app/store/domain/slice.ts +++ b/src/app/store/domain/slice.ts @@ -176,7 +176,7 @@ const domainSlice = createSlice({ payload: { params: params, }, - }; + } as const; }, reducer: () => {}, }, @@ -206,7 +206,7 @@ const domainSlice = createSlice({ payload: { params, }, - }; + } as const; }, reducer: () => {}, }, @@ -236,7 +236,7 @@ const domainSlice = createSlice({ payload: { params: params, }, - }; + } as const; }, reducer: () => {}, }, @@ -267,7 +267,7 @@ const domainSlice = createSlice({ payload: { params, }, - }; + } as const; }, reducer: () => {}, }, @@ -297,7 +297,7 @@ const domainSlice = createSlice({ payload: { params, }, - }; + } as const; }, reducer: () => {}, }, @@ -327,7 +327,7 @@ const domainSlice = createSlice({ payload: { params: params, }, - }; + } as const; }, reducer: () => {}, }, diff --git a/src/app/store/general/slice.ts b/src/app/store/general/slice.ts index 815f8beea8..7de84171f8 100644 --- a/src/app/store/general/slice.ts +++ b/src/app/store/general/slice.ts @@ -5,9 +5,9 @@ import type { GeneralState, GenerateCertificateParams } from "./types"; import { GeneralMeta } from "./types"; const generateInitialState = ( - initialData: GeneralState[K]["data"] + queryData: GeneralState[K]["data"] ) => ({ - data: initialData, + data: queryData, errors: null, loaded: false, loading: false, diff --git a/src/app/store/machine/slice.ts b/src/app/store/machine/slice.ts index 7b50087f68..3918f4ea49 100644 --- a/src/app/store/machine/slice.ts +++ b/src/app/store/machine/slice.ts @@ -237,7 +237,7 @@ const generateActionParams =

( payload: { params: actionParams, }, - }; + } as const; }, reducer: () => {}, }); @@ -1118,14 +1118,15 @@ const machineSlice = createSlice({ }, }, cleanupRequest: { - prepare: (callId: string) => ({ - meta: { - callId, - model: MachineMeta.MODEL, - unsubscribe: true, - }, - payload: null, - }), + prepare: (callId: string) => + ({ + meta: { + callId, + model: MachineMeta.MODEL, + unsubscribe: true, + }, + payload: null, + }) as const, reducer: () => {}, }, get: { @@ -1360,12 +1361,13 @@ const machineSlice = createSlice({ [`${NodeActions.RELEASE}Start`]: statusHandlers.release.start, [`${NodeActions.RELEASE}Success`]: statusHandlers.release.success, removeRequest: { - prepare: (callId: string) => ({ - meta: { - callId, - }, - payload: null, - }), + prepare: (callId: string) => + ({ + meta: { + callId, + }, + payload: null, + }) as const, reducer: ( state: MachineState, action: PayloadAction @@ -1572,15 +1574,16 @@ const machineSlice = createSlice({ reducer: () => {}, }, unsubscribe: { - prepare: (ids: Machine[MachineMeta.PK][]) => ({ - meta: { - model: MachineMeta.MODEL, - method: "unsubscribe", - }, - payload: { - params: { system_ids: ids }, - }, - }), + prepare: (ids: Machine[MachineMeta.PK][]) => + ({ + meta: { + model: MachineMeta.MODEL, + method: "unsubscribe", + }, + payload: { + params: { system_ids: ids }, + }, + }) as const, reducer: () => {}, }, unsubscribeError: ( diff --git a/src/app/store/utils/slice.ts b/src/app/store/utils/slice.ts index 7095263fd0..634881f869 100644 --- a/src/app/store/utils/slice.ts +++ b/src/app/store/utils/slice.ts @@ -225,15 +225,16 @@ export const generateCommonReducers = < state.items = action.payload; }, create: { - prepare: (params: CreateParams) => ({ - meta: { - model: modelName, - method: "create", - }, - payload: { - params, - }, - }), + prepare: (params: CreateParams) => + ({ + meta: { + model: modelName, + method: "create", + }, + payload: { + params, + }, + }) as const, reducer: () => {}, }, createStart: (state: S) => { diff --git a/src/app/store/zone/actions.test.ts b/src/app/store/zone/actions.test.ts index 9e03f4b13a..fa7a3b1b73 100644 --- a/src/app/store/zone/actions.test.ts +++ b/src/app/store/zone/actions.test.ts @@ -24,13 +24,6 @@ it("can create an action for creating a zone", () => { }); }); -it("can create an action for fetching zones", () => { - expect(zoneActions[ZONE_ACTIONS.fetch]()).toEqual({ - type: `${ZoneMeta.MODEL}/${ZONE_ACTIONS.fetch}`, - payload: null, - }); -}); - it("can create an action for deleting a zone", () => { expect(zoneActions[ZONE_ACTIONS.delete]({ [ZoneMeta.PK]: 123 })).toEqual({ type: `${ZoneMeta.MODEL}/${ZONE_ACTIONS.delete}`, diff --git a/src/app/store/zone/reducers.test.ts b/src/app/store/zone/reducers.test.ts index 91d4f642d3..62a9a109a4 100644 --- a/src/app/store/zone/reducers.test.ts +++ b/src/app/store/zone/reducers.test.ts @@ -4,7 +4,6 @@ import reducers, { initialGenericActions, initialModelActions, } from "./slice"; -import { ZoneMeta } from "./types"; import { ACTION_STATUS } from "@/app/base/constants"; import * as factory from "@/testing/factories"; @@ -115,80 +114,6 @@ describe("create", () => { }) ); }); - - it("reduces createNotify", () => { - const initialState = factory.zoneState({ - items: [factory.zone()], - }); - const createdZone = factory.zone(); - - expect(reducers(initialState, actions.createNotify(createdZone))).toEqual( - factory.zoneState({ - items: [...initialState.items, createdZone], - }) - ); - }); -}); - -describe("fetch", () => { - it("reduces fetchStart", () => { - const initialState = factory.zoneState({ - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.idle, - }), - }); - - expect(reducers(initialState, actions.fetchStart())).toEqual( - factory.zoneState({ - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.loading, - }), - }) - ); - }); - - it("reduces fetchSuccess", () => { - const initialState = factory.zoneState({ - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.loading, - }), - items: [], - }); - const fetchedZones = [factory.zone(), factory.zone()]; - - expect(reducers(initialState, actions.fetchSuccess(fetchedZones))).toEqual( - factory.zoneState({ - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.success, - }), - items: fetchedZones, - }) - ); - }); - - it("reduces fetchError", () => { - const initialState = factory.zoneState({ - errors: [], - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.loading, - }), - }); - const errorMessage = "Unable to fetch zones"; - - expect(reducers(initialState, actions.fetchError(errorMessage))).toEqual( - factory.zoneState({ - errors: [ - factory.zoneError({ - action: ZONE_ACTIONS.fetch, - error: errorMessage, - }), - ], - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.error, - }), - }) - ); - }); }); describe("update", () => { @@ -224,7 +149,6 @@ describe("update", () => { it("reduces updateSuccess", () => { const zone = factory.zone({ id: 123 }); const initialState = factory.zoneState({ - items: [], modelActions: factory.zoneModelActions({ [ZONE_ACTIONS.update]: factory.zoneModelAction({ [ACTION_STATUS.loading]: [123], @@ -293,19 +217,6 @@ describe("update", () => { }) ); }); - - it("reduces updateNotify", () => { - const initialState = factory.zoneState({ - items: [factory.zone({ [ZoneMeta.PK]: 123, name: "danger" })], - }); - const updatedZone = factory.zone({ [ZoneMeta.PK]: 123, name: "twilight" }); - - expect(reducers(initialState, actions.updateNotify(updatedZone))).toEqual( - factory.zoneState({ - items: [updatedZone], - }) - ); - }); }); describe("delete", () => { @@ -408,16 +319,4 @@ describe("delete", () => { }) ); }); - - it("reduces deleteNotify", () => { - const initialState = factory.zoneState({ - items: [factory.zone({ [ZoneMeta.PK]: 123 })], - }); - - expect(reducers(initialState, actions.deleteNotify(123))).toEqual( - factory.zoneState({ - items: [], - }) - ); - }); }); diff --git a/src/app/store/zone/selectors.test.ts b/src/app/store/zone/selectors.test.ts index e3cbc6ef9a..dc6a3a5c93 100644 --- a/src/app/store/zone/selectors.test.ts +++ b/src/app/store/zone/selectors.test.ts @@ -4,17 +4,6 @@ import zone from "./selectors"; import { ACTION_STATUS } from "@/app/base/constants"; import * as factory from "@/testing/factories"; -it("can get all zones", () => { - const items = [factory.zone(), factory.zone()]; - const state = factory.rootState({ - zone: factory.zoneState({ - items, - }), - }); - - expect(zone.all(state)).toEqual(items); -}); - it("can get zone errors", () => { const errors = [factory.zoneError(), factory.zoneError()]; const state = factory.rootState({ @@ -48,31 +37,6 @@ it("can get zone model actions", () => { expect(zone.modelActions(state)).toEqual(modelActions); }); -it("can get the zone count", () => { - const items = [factory.zone(), factory.zone()]; - const state = factory.rootState({ - zone: factory.zoneState({ - items, - }), - }); - - expect(zone.count(state)).toEqual(items.length); -}); - -it("can get a zone by id", () => { - const [thisZone, otherZone] = [ - factory.zone({ id: 1 }), - factory.zone({ id: 2 }), - ]; - const state = factory.rootState({ - zone: factory.zoneState({ - items: [thisZone, otherZone], - }), - }); - - expect(zone.getById(state, 1)).toStrictEqual(thisZone); -}); - it("can get a zone generic action's status", () => { const genericActions = factory.zoneGenericActions({ [ZONE_ACTIONS.fetch]: ACTION_STATUS.error, @@ -105,30 +69,6 @@ it("can get a zone's model action status", () => { ); }); -it("can get the zone loading state", () => { - const state = factory.rootState({ - zone: factory.zoneState({ - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.loading, - }), - }), - }); - - expect(zone.loading(state)).toEqual(true); -}); - -it("can get the zone loaded state", () => { - const state = factory.rootState({ - zone: factory.zoneState({ - genericActions: factory.zoneGenericActions({ - [ZONE_ACTIONS.fetch]: ACTION_STATUS.success, - }), - }), - }); - - expect(zone.loaded(state)).toEqual(true); -}); - it("can get the zone creating state", () => { const state = factory.rootState({ zone: factory.zoneState({ diff --git a/src/app/store/zone/selectors.ts b/src/app/store/zone/selectors.ts index cfe90d520c..8d8e1e61a7 100644 --- a/src/app/store/zone/selectors.ts +++ b/src/app/store/zone/selectors.ts @@ -12,10 +12,6 @@ import { ZoneMeta } from "./types"; import { ACTION_STATUS } from "@/app/base/constants"; import type { RootState } from "@/app/store/root/types"; -import { isId } from "@/app/utils"; - -const all = (state: RootState): ZoneState["items"] => - state[ZoneMeta.MODEL].items; const errors = (state: RootState): ZoneState["errors"] => state[ZoneMeta.MODEL].errors; @@ -26,18 +22,6 @@ const genericActions = (state: RootState): ZoneState["genericActions"] => const modelActions = (state: RootState): ZoneState["modelActions"] => state[ZoneMeta.MODEL].modelActions; -const count = createSelector([all], (zones) => zones.length); - -const getById = createSelector( - [all, (_state: RootState, id: ZonePK | null | undefined) => id], - (zones, id) => { - if (!isId(id)) { - return null; - } - return zones.find((zone) => zone[ZoneMeta.PK] === id) || null; - } -); - const getGenericActionStatus = createSelector( (state: RootState, action: keyof ZoneGenericActions) => ({ action, @@ -65,12 +49,6 @@ const getModelActionStatus = createSelector( } ); -const loaded = (state: RootState): boolean => - getGenericActionStatus(state, ZONE_ACTIONS.fetch) === ACTION_STATUS.success; - -const loading = (state: RootState): boolean => - getGenericActionStatus(state, ZONE_ACTIONS.fetch) === ACTION_STATUS.loading; - const created = (state: RootState): boolean => getGenericActionStatus(state, ZONE_ACTIONS.create) === ACTION_STATUS.success; @@ -92,18 +70,13 @@ const getLatestError = createSelector( ); const selectors = { - all, - count, created, creating, errors, genericActions, - getById, getGenericActionStatus, getLatestError, getModelActionStatus, - loaded, - loading, modelActions, }; diff --git a/src/app/store/zone/slice.ts b/src/app/store/zone/slice.ts index d41db09cbc..9fafbffe08 100644 --- a/src/app/store/zone/slice.ts +++ b/src/app/store/zone/slice.ts @@ -120,17 +120,6 @@ const zoneSlice = createSlice({ addError(state, create, action.payload); updateGenericAction(state, create, error); }, - createNotify: (state, action: PayloadAction) => { - const existingIdx = state.items.findIndex( - (existingItem) => - existingItem[ZoneMeta.PK] === action.payload[ZoneMeta.PK] - ); - if (existingIdx !== -1) { - state.items[existingIdx] = action.payload; - } else { - state.items.push(action.payload); - } - }, createStart: (state) => { updateGenericAction(state, create, loading); }, @@ -157,12 +146,6 @@ const zoneSlice = createSlice({ updateModelAction(state, deleteAction, error, action.meta.identifier); }, }, - deleteNotify: (state, action: PayloadAction) => { - const index = state.items.findIndex( - (item) => item[ZoneMeta.PK] === action.payload - ); - state.items.splice(index, 1); - }, deleteStart: { prepare: (action: ZonePayloadActionWithIdentifier) => action, reducer: (state, action: ZonePayloadActionWithIdentifier) => { @@ -175,23 +158,6 @@ const zoneSlice = createSlice({ updateModelAction(state, deleteAction, success, action.meta.identifier); }, }, - [fetch]: { - prepare: () => ({ - payload: null, - }), - reducer: () => {}, - }, - fetchError: (state, action: PayloadAction) => { - addError(state, fetch, action.payload); - updateGenericAction(state, fetch, error); - }, - fetchStart: (state) => { - updateGenericAction(state, fetch, loading); - }, - fetchSuccess: (state, action: PayloadAction) => { - state.items = action.payload; - updateGenericAction(state, fetch, success); - }, [update]: { prepare: (params: UpdateParams) => ({ meta: { @@ -212,13 +178,6 @@ const zoneSlice = createSlice({ updateModelAction(state, update, error, action.meta.identifier); }, }, - updateNotify: (state, action: PayloadAction) => { - state.items.forEach((zone, i) => { - if (zone[ZoneMeta.PK] === action.payload[ZoneMeta.PK]) { - state.items[i] = action.payload; - } - }); - }, updateStart: { prepare: (action: ZonePayloadActionWithIdentifier) => action, reducer: (state, action: ZonePayloadActionWithIdentifier) => { diff --git a/src/app/store/zone/types/base.ts b/src/app/store/zone/types/base.ts index a0b5bf2964..31fd12a182 100644 --- a/src/app/store/zone/types/base.ts +++ b/src/app/store/zone/types/base.ts @@ -44,6 +44,5 @@ export type ZoneStateError = StateError; export type ZoneState = { errors: ZoneStateError[]; genericActions: ZoneGenericActions; - items: Zone[]; modelActions: ZoneModelActions; }; diff --git a/src/app/tags/components/TagsHeader/AddTagForm/AddTagForm.test.tsx b/src/app/tags/components/TagsHeader/AddTagForm/AddTagForm.test.tsx index 2263a0aba9..35e0def08b 100644 --- a/src/app/tags/components/TagsHeader/AddTagForm/AddTagForm.test.tsx +++ b/src/app/tags/components/TagsHeader/AddTagForm/AddTagForm.test.tsx @@ -18,7 +18,7 @@ import { render, screen, waitFor, - renderWithBrowserRouter, + renderWithHistoryRouter, } from "@/testing/utils"; const mockStore = configureStore(); @@ -33,6 +33,7 @@ beforeEach(() => { it("dispatches an action to create a tag", async () => { const store = mockStore(state); + render( @@ -40,29 +41,43 @@ it("dispatches an action to create a tag", async () => { ); + await userEvent.type( screen.getByRole("textbox", { name: Label.Name }), + "name1" ); + await userEvent.type( screen.getByRole("textbox", { name: DefinitionLabel.Definition }), + "definition1" ); + await userEvent.type( screen.getByRole("textbox", { name: Label.Comment }), + "comment1" ); + await userEvent.type( screen.getByRole("textbox", { name: KernelOptionsLabel.KernelOptions }), + "options1" ); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + const expected = tagActions.create({ comment: "comment1", + definition: "definition1", + kernel_opts: "options1", + name: "name1", }); + await waitFor(() => expect( store.getActions().find((action) => action.type === expected.type) @@ -72,11 +87,16 @@ it("dispatches an action to create a tag", async () => { it("redirects to the newly created tag on save", async () => { const onClose = vi.fn(); - renderWithBrowserRouter(, { - route: urls.tags.index, - state, - }); - expect(window.location.pathname).toBe(urls.tags.index); + const initialEntries = [{ pathname: urls.tags.index }]; + const { history } = renderWithHistoryRouter( + , + { + state, + initialEntries, + } + ); + + expect(history.location.pathname).toBe(urls.tags.index); await userEvent.type( screen.getByRole("textbox", { name: Label.Name }), "tag1" @@ -88,19 +108,24 @@ it("redirects to the newly created tag on save", async () => { saved: true, }); await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { - expect(window.location.pathname).toBe(urls.tags.tag.index({ id: 8 })); + expect(history.location.pathname).toBe(urls.tags.tag.index({ id: 8 })); }); - expect(onClose).toBeCalled(); + expect(onClose).toHaveBeenCalled(); }); it("sends analytics when there is a definition", async () => { const mockSendAnalytics = vi.fn(); + vi.spyOn(analyticsHooks, "useSendAnalytics").mockImplementation( () => mockSendAnalytics ); + const onClose = vi.fn(); + const store = mockStore(state); + const TagForm = () => ( @@ -108,35 +133,49 @@ it("sends analytics when there is a definition", async () => { ); + render(); + await userEvent.type( screen.getByRole("textbox", { name: Label.Name }), + "tag1" ); mockFormikFormSaved(); + state.tag = factory.tagState({ items: [factory.tag({ id: 8, name: "tag1", definition: "def1" })], + saved: true, }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { expect(mockSendAnalytics).toHaveBeenCalled(); }); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ "XPath tagging", + "Valid XPath", + "Save", ]); }); it("sends analytics when there is no definition", async () => { const mockSendAnalytics = vi.fn(); + vi.spyOn(analyticsHooks, "useSendAnalytics").mockImplementation( () => mockSendAnalytics ); + const onClose = vi.fn(); + const store = mockStore(state); + const TagForm = () => ( @@ -144,30 +183,41 @@ it("sends analytics when there is no definition", async () => { ); + render(); + await userEvent.type( screen.getByRole("textbox", { name: Label.Name }), + "tag1" ); mockFormikFormSaved(); + state.tag = factory.tagState({ items: [factory.tag({ id: 8, name: "tag1" })], + saved: true, }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { expect(mockSendAnalytics).toHaveBeenCalled(); }); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ "Create Tag form", + "Manual tag created", + "Save", ]); }); it("shows a confirmation when an automatic tag is added", async () => { const store = mockStore(state); + render( @@ -178,29 +228,40 @@ it("shows a confirmation when an automatic tag is added", async () => { await userEvent.type( screen.getByRole("textbox", { name: Label.Name }), + "name1" ); + await userEvent.type( screen.getByRole("textbox", { name: DefinitionLabel.Definition, }), + "definition" ); + // Mock state.tag.saved transitioning from "false" to "true" + mockFormikFormSaved(); + await userEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { const action = store + .getActions() + .find((action) => action.type === "message/add"); + const strippedMessage = action.payload.message.replace(/\s+/g, " ").trim(); + expect(strippedMessage).toBe(`Created name1. ${NewDefinitionMessage}`); }); }); it("shows an error if tag name is invalid", async () => { const store = mockStore(state); + render( @@ -210,7 +271,9 @@ it("shows an error if tag name is invalid", async () => { ); const nameInput = screen.getByRole("textbox", { name: Label.Name }); + await userEvent.type(nameInput, "invalid name"); + await userEvent.tab(); await waitFor(() => diff --git a/src/app/tags/views/TagMachines/TagMachines.test.tsx b/src/app/tags/views/TagMachines/TagMachines.test.tsx index 1890f6305f..e4bddd614f 100644 --- a/src/app/tags/views/TagMachines/TagMachines.test.tsx +++ b/src/app/tags/views/TagMachines/TagMachines.test.tsx @@ -1,7 +1,3 @@ -import { Provider } from "react-redux"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import TagMachines, { Label } from "./TagMachines"; import urls from "@/app/base/urls"; @@ -13,11 +9,14 @@ import type { RootState } from "@/app/store/root/types"; import { tagActions } from "@/app/store/tag"; import { NodeStatus, FetchNodeStatus } from "@/app/store/types/node"; import * as factory from "@/testing/factories"; -import { render, screen } from "@/testing/utils"; +import { renderWithBrowserRouter, screen } from "@/testing/utils"; const callId = "mocked-nanoid"; -const mockStore = configureStore(); let state: RootState; +const routeOptions = { + route: urls.tags.tag.index({ id: 1 }), + routePattern: urls.tags.tag.index(null), +}; beforeEach(() => { vi.spyOn(query, "generateCallId").mockReturnValue(callId); @@ -61,18 +60,11 @@ afterEach(() => { }); it("dispatches actions to fetch necessary data", () => { - const store = mockStore(state); - render( - - - - } path={urls.tags.tag.index(null)} /> - - - - ); + const { store } = renderWithBrowserRouter(, { + state, + ...routeOptions, + }); + const expectedActions = [ machineActions.fetch(callId, { filter: { @@ -105,18 +97,10 @@ it("displays a message if the tag does not exist", () => { loading: false, }), }); - const store = mockStore(state); - render( - - - - } path={urls.tags.tag.index(null)} /> - - - - ); + renderWithBrowserRouter(, { + state, + ...routeOptions, + }); expect(screen.getByText("Tag not found")).toBeInTheDocument(); }); @@ -127,38 +111,22 @@ it("shows a spinner if the tag has not loaded yet", () => { loading: true, }), }); - const store = mockStore(state); - render( - - - - } path={urls.tags.tag.index(null)} /> - - - - ); + renderWithBrowserRouter(, { + state, + ...routeOptions, + }); expect(screen.getByTestId("Spinner")).toBeInTheDocument(); }); -it("displays the machine list", () => { - const store = mockStore(state); - render( - - - - } path={urls.tags.tag.index(null)} /> - - - - ); +it("displays the machine list", async () => { + renderWithBrowserRouter(, { + state, + ...routeOptions, + }); expect( - screen.getByRole("grid", { name: Label.Machines }) + await screen.findByRole("grid", { name: Label.Machines }) ).toBeInTheDocument(); - const rows = screen.getAllByRole("gridcell", { + const rows = await screen.findAllByRole("gridcell", { name: columnLabels[MachineColumns.FQDN], }); expect(rows).toHaveLength(1); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetails.test.tsx b/src/app/zones/views/ZoneDetails/ZoneDetails.test.tsx index 61bd129c57..6a6b53c7d2 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetails.test.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetails.test.tsx @@ -1,53 +1,36 @@ -import { Provider } from "react-redux"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import ZoneDetails from "./ZoneDetails"; import { Labels } from "@/app/base/components/EditableSection"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { render, screen } from "@/testing/utils"; - -const mockStore = configureStore(); +import { renderWithBrowserRouter, screen } from "@/testing/utils"; describe("ZoneDetails", () => { - let initialState: RootState; - - const testZones = factory.zoneState({ - items: [ - factory.zone({ - id: 1, - name: "zone-name", - }), - ], - }); - + const testZones = [ + factory.zone({ + id: 1, + name: "zone-name", + }), + ]; + let state: RootState; + const queryData = { zones: testZones }; beforeEach(() => { - initialState = factory.rootState({ + state = factory.rootState({ user: factory.userState({ auth: factory.authState({ user: factory.user({ is_superuser: true }), }), }), - zone: testZones, }); }); it("shows Edit button if user is admin", () => { - const state = initialState; - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - - ); + renderWithBrowserRouter(, { + route: "/zone/1", + routePattern: "/zone/:id", + queryData, + state, + }); const editButtons = screen.getAllByRole("button", { name: Labels.EditButton, @@ -62,20 +45,13 @@ describe("ZoneDetails", () => { user: factory.user({ is_superuser: false }), }), }), - zone: testZones, }); - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - - ); + renderWithBrowserRouter(, { + route: "/zone/1", + routePattern: "/zone/:id", + queryData, + state, + }); const editButtons = screen.queryAllByRole("button", { name: Labels.EditButton, diff --git a/src/app/zones/views/ZoneDetails/ZoneDetails.tsx b/src/app/zones/views/ZoneDetails/ZoneDetails.tsx index b7dc7b31c4..fcf1329b6e 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetails.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetails.tsx @@ -4,31 +4,25 @@ import ZoneDetailsContent from "./ZoneDetailsContent"; import ZoneDetailsForm from "./ZoneDetailsForm"; import ZoneDetailsHeader from "./ZoneDetailsHeader"; +import { useZoneById } from "@/app/api/query/zones"; import EditableSection from "@/app/base/components/EditableSection"; import ModelNotFound from "@/app/base/components/ModelNotFound"; import PageContent from "@/app/base/components/PageContent"; -import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; +import { useWindowTitle } from "@/app/base/hooks"; import { useGetURLId } from "@/app/base/hooks/urls"; import urls from "@/app/base/urls"; import authSelectors from "@/app/store/auth/selectors"; -import type { RootState } from "@/app/store/root/types"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; import { ZoneMeta } from "@/app/store/zone/types"; import { isId } from "@/app/utils"; const ZoneDetails = (): JSX.Element => { const zoneID = useGetURLId(ZoneMeta.PK); const isAdmin = useSelector(authSelectors.isAdmin); - const zonesLoading = useSelector(zoneSelectors.loading); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, zoneID) - ); - useWindowTitle(zone?.name ?? "Loading..."); + const zone = useZoneById(zoneID); - useFetchActions([zoneActions.fetch]); + useWindowTitle(zone?.data?.name ?? "Loading..."); - if (!isId(zoneID) || (!zonesLoading && !zone)) { + if (!isId(zoneID) || (!zone.isPending && !zone.data)) { return ( ); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsContent/ZoneDetailsContent.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsContent/ZoneDetailsContent.tsx index 2bb58be614..537223c571 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsContent/ZoneDetailsContent.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsContent/ZoneDetailsContent.tsx @@ -1,30 +1,22 @@ import { Row, Col } from "@canonical/react-components"; -import { useSelector } from "react-redux"; +import { useZoneById } from "@/app/api/query/zones"; import Definition from "@/app/base/components/Definition"; -import { useFetchActions } from "@/app/base/hooks"; -import type { RootState } from "@/app/store/root/types"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { id: number; }; const ZoneDetailsContent = ({ id }: Props): JSX.Element | null => { - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, id) - ); + const zone = useZoneById(id); - useFetchActions([zoneActions.fetch]); - - if (zone) { + if (zone.data) { return ( - {zone.name} - {zone.description} - {`${zone.machines_count}`} + {zone.data.name} + {zone.data.description} + {`${zone.data.machines_count}`} ); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.test.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.test.tsx index e275ce8cca..df650ecaaf 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.test.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.test.tsx @@ -1,37 +1,23 @@ -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import ZoneDetailsForm from "./ZoneDetailsForm"; -import type { RootState } from "@/app/store/root/types"; import { zoneActions } from "@/app/store/zone"; import * as factory from "@/testing/factories"; -import { userEvent, render, screen, waitFor } from "@/testing/utils"; - -const mockStore = configureStore(); +import { + userEvent, + screen, + waitFor, + renderWithBrowserRouter, +} from "@/testing/utils"; describe("ZoneDetailsForm", () => { const testZone = factory.zone(); - let initialState: RootState; - - beforeEach(() => { - initialState = factory.rootState({ - zone: factory.zoneState({ - items: [testZone], - }), - }); - }); + const queryData = { zones: [testZone] }; it("runs closeForm function when the cancel button is clicked", async () => { const closeForm = vi.fn(); - const store = mockStore(initialState); - render( - - - - - + renderWithBrowserRouter( + , + { queryData } ); await userEvent.click(screen.getByRole("button", { name: "Cancel" })); @@ -39,13 +25,9 @@ describe("ZoneDetailsForm", () => { }); it("calls actions.update on save click", async () => { - const store = mockStore(initialState); - render( - - - - - + const { store } = renderWithBrowserRouter( + , + { queryData } ); await userEvent.clear(screen.getByLabelText("Name")); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.tsx index f052e81e03..971f8d5f0a 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsForm/ZoneDetailsForm.tsx @@ -3,10 +3,10 @@ import { useCallback } from "react"; import { Row, Col, Textarea } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; +import { useZoneById } from "@/app/api/query/zones"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; import { ACTION_STATUS } from "@/app/base/constants"; -import { useFetchActions } from "@/app/base/hooks"; import type { RootState } from "@/app/store/root/types"; import { zoneActions } from "@/app/store/zone"; import { ZONE_ACTIONS } from "@/app/store/zone/constants"; @@ -28,9 +28,7 @@ const ZoneDetailsForm = ({ id, closeForm }: Props): JSX.Element | null => { () => zoneActions.cleanup([ZONE_ACTIONS.update]), [] ); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, id) - ); + const { data: zone } = useZoneById(id); const errors = useSelector((state: RootState) => zoneSelectors.getLatestError(state, ZONE_ACTIONS.update, id) ); @@ -38,8 +36,6 @@ const ZoneDetailsForm = ({ id, closeForm }: Props): JSX.Element | null => { zoneSelectors.getModelActionStatus(state, ZONE_ACTIONS.update, id) ); - useFetchActions([zoneActions.fetch]); - if (zone) { return ( diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx index 78a77abcfc..636931a284 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx @@ -1,43 +1,29 @@ -import { Provider } from "react-redux"; -import configureStore from "redux-mock-store"; - import DeleteConfirm from "./DeleteConfirm"; -import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { userEvent, screen, render } from "@/testing/utils"; - -const mockStore = configureStore(); +import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; describe("DeleteConfirm", () => { - let initialState: RootState; - - beforeEach(() => { - initialState = factory.rootState({ - zone: factory.zoneState({ - items: [ - factory.zone({ - id: 1, - name: "zone-name", - }), - ], + const queryData = { + zones: [ + factory.zone({ + id: 1, + name: "zone-name", }), - }); - }); + ], + }; it("runs onConfirm function when Delete AZ is clicked", async () => { const closeExpanded = vi.fn(); const onConfirm = vi.fn(); - const store = mockStore(initialState); - render( - - - + renderWithBrowserRouter( + , + { route: "/zones", queryData } ); await userEvent.click(screen.getByRole("button", { name: "Delete AZ" })); @@ -47,16 +33,14 @@ describe("DeleteConfirm", () => { it("runs closeExpanded function when cancel is clicked", async () => { const closeExpanded = vi.fn(); const onConfirm = vi.fn(); - const store = mockStore(initialState); - render( - - - + renderWithBrowserRouter( + , + { route: "/zones", queryData } ); await userEvent.click(screen.getByRole("button", { name: "Cancel" })); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx index 770dc55687..845dcf4cae 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx @@ -1,137 +1,100 @@ -import { Provider } from "react-redux"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; -import configureStore from "redux-mock-store"; - import ZoneDetailsHeader from "./ZoneDetailsHeader"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { screen, render, within } from "@/testing/utils"; - -const mockStore = configureStore(); +import { + renderWithBrowserRouter, + screen, + waitFor, + within, +} from "@/testing/utils"; describe("ZoneDetailsHeader", () => { - let initialState: RootState; - - const testZones = factory.zoneState({ - genericActions: factory.zoneGenericActions({ fetch: "success" }), - items: [ - factory.zone({ - id: 1, - name: "zone-name", - }), - factory.zone({ - id: 2, - name: "zone2-name", - }), + let state: RootState; + const queryData = { + zones: [ + factory.zone({ id: 1, name: "zone-name" }), + factory.zone({ id: 2, name: "zone-name-2" }), ], - }); + }; beforeEach(() => { - initialState = factory.rootState({ + state = factory.rootState({ user: factory.userState({ auth: factory.authState({ user: factory.user({ is_superuser: true }), }), }), - zone: testZones, + zone: factory.zoneState({ + genericActions: factory.zoneGenericActions({ fetch: "success" }), + }), }); }); - it("displays zone name in header if one exists", () => { - const state = initialState; - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - - ); + it("displays zone name in header if one exists", async () => { + renderWithBrowserRouter(, { + state, + queryData, + route: "/zone/1", + }); - const { getByText } = within(screen.getByTestId("section-header-title")); - expect(getByText("Availability zone: zone-name")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("section-header-title")).toHaveTextContent( + "Availability zone: zone-name" + ); + }); }); - it("displays not found message if no zone exists", () => { - const state = initialState; - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - + it("displays not found message if no zone exists", async () => { + renderWithBrowserRouter(, { + state, + queryData, + route: "/zone/3", + }); + + const { findByText } = within( + await screen.findByTestId("section-header-title") ); - const { getByText } = within(screen.getByTestId("section-header-title")); - expect(getByText("Availability zone not found")).toBeInTheDocument(); + expect(await findByText("Availability zone not found")).toBeInTheDocument(); }); - it("shows delete az button when zone id isn't 1", () => { - const state = initialState; - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - - ); + it("shows delete az button when zone id isn't 1", async () => { + renderWithBrowserRouter(, { + state, + queryData, + route: "/zone/2", + }); + expect( - screen.getByRole("button", { name: "Delete AZ" }) + await screen.findByRole("button", { name: "Delete AZ" }) ).toBeInTheDocument(); }); it("hides delete button when zone id is 1 (as this is the default)", () => { - const state = initialState; - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - - ); + renderWithBrowserRouter(, { + state, + queryData, + route: "/zone/1", + }); + expect(screen.queryByTestId("delete-zone")).not.toBeInTheDocument(); }); it("hides delete button for all zones when user isn't admin", () => { - const state = factory.rootState({ + const nonAdminState = factory.rootState({ user: factory.userState({ auth: factory.authState({ user: factory.user({ is_superuser: false }), }), }), - zone: testZones, }); - const store = mockStore(state); - render( - - - - } path="/zone/:id" /> - - - - ); + + renderWithBrowserRouter(, { + state: nonAdminState, + queryData, + route: "/zone/2", + }); + expect(screen.queryByTestId("delete-zone")).not.toBeInTheDocument(); }); }); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx index de4ad17684..dc76f7371b 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx @@ -6,8 +6,8 @@ import { useNavigate } from "react-router-dom"; import DeleteConfirm from "./DeleteConfirm"; +import { useZoneById } from "@/app/api/query/zones"; import SectionHeader from "@/app/base/components/SectionHeader"; -import { useFetchActions } from "@/app/base/hooks"; import urls from "@/app/base/urls"; import authSelectors from "@/app/store/auth/selectors"; import type { RootState } from "@/app/store/root/types"; @@ -21,17 +21,19 @@ type Props = { const ZoneDetailsHeader = ({ id }: Props): JSX.Element => { const [showConfirm, setShowConfirm] = useState(false); - const zonesLoaded = useSelector(zoneSelectors.loaded); const deleteStatus = useSelector((state: RootState) => zoneSelectors.getModelActionStatus(state, ZONE_ACTIONS.delete, id) ); - const zone = useSelector((state: RootState) => - zoneSelectors.getById(state, Number(id)) - ); + const zone = useZoneById(id); const dispatch = useDispatch(); const navigate = useNavigate(); + let title = ""; - useFetchActions([zoneActions.fetch]); + if (!zone.isPending) { + title = zone.data + ? `Availability zone: ${zone.data.name}` + : "Availability zone not found"; + } useEffect(() => { if (deleteStatus === "success") { @@ -65,8 +67,6 @@ const ZoneDetailsHeader = ({ id }: Props): JSX.Element => { buttons = null; } - let title = ""; - let confirmDelete = null; if (showConfirm && isAdmin && !isDefaultZone) { @@ -84,16 +84,20 @@ const ZoneDetailsHeader = ({ id }: Props): JSX.Element => { ); } - if (zonesLoaded && zone) { - title = `Availability zone: ${zone.name}`; - } else if (zonesLoaded) { + if (!zone.isPending && zone.data) { + title = `Availability zone: ${zone.data.name}`; + } else if (zone.isFetched) { title = "Availability zone not found"; buttons = null; } return ( <> - + {confirmDelete} diff --git a/src/app/zones/views/ZonesList/ZonesList.test.tsx b/src/app/zones/views/ZonesList/ZonesList.test.tsx index 1da56c2321..1bff1e7d92 100644 --- a/src/app/zones/views/ZonesList/ZonesList.test.tsx +++ b/src/app/zones/views/ZonesList/ZonesList.test.tsx @@ -1,45 +1,31 @@ -import configureStore from "redux-mock-store"; - import ZonesList, { TestIds } from "./ZonesListTable/ZonesListTable"; -import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { renderWithBrowserRouter, screen } from "@/testing/utils"; -const mockStore = configureStore(); - describe("ZonesList", () => { - it("correctly fetches the necessary data", () => { - const state = factory.rootState(); - const store = mockStore(state); - renderWithBrowserRouter(, { route: "/zones", store }); - const expectedActions = ["zone/fetch"]; - const actualActions = store.getActions(); - expect( - expectedActions.every((expectedAction) => - actualActions.some((action) => action.type === expectedAction) - ) - ).toBe(true); + it("correctly fetches the necessary data", async () => { + const queryData = { zones: [factory.zone({ name: "zone-1" })] }; + renderWithBrowserRouter(, { + route: "/zones", + queryData, + }); + + expect(await screen.findByText("zone-1")).toBeInTheDocument(); }); it("shows a zones table if there are any zones", () => { - const state = factory.rootState({ - zone: factory.zoneState({ - items: [factory.zone({ name: "test" })], - }), - }); - renderWithBrowserRouter(, { route: "/zones", state }); + const queryData = { + zones: [factory.zone({ name: "test" })], + }; + renderWithBrowserRouter(, { route: "/zones", queryData }); expect(screen.getByTestId(TestIds.ZonesTable)).toBeInTheDocument(); }); it("shows a message if there are no zones", () => { - const state = factory.rootState({ - zone: factory.zoneState({ - items: [], - }), - }); - renderWithBrowserRouter(, { route: "/zones", state }); + const queryData = { zones: [] }; + renderWithBrowserRouter(, { route: "/zones", queryData }); expect(screen.getByText("No zones available.")).toBeInTheDocument(); }); diff --git a/src/app/zones/views/ZonesList/ZonesList.tsx b/src/app/zones/views/ZonesList/ZonesList.tsx index 5ee916f380..78b005d8ea 100644 --- a/src/app/zones/views/ZonesList/ZonesList.tsx +++ b/src/app/zones/views/ZonesList/ZonesList.tsx @@ -1,24 +1,19 @@ -import { useSelector } from "react-redux"; - import ZonesListForm from "./ZonesListForm"; import ZonesListHeader from "./ZonesListHeader"; import ZonesListTable from "./ZonesListTable"; +import { useZoneCount } from "@/app/api/query/zones"; import PageContent from "@/app/base/components/PageContent"; -import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; +import { useWindowTitle } from "@/app/base/hooks"; import { useSidePanel } from "@/app/base/side-panel-context"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; import { ZoneActionSidePanelViews } from "@/app/zones/constants"; const ZonesList = (): JSX.Element => { - const zonesCount = useSelector(zoneSelectors.count); + const zonesCount = useZoneCount(); const { sidePanelContent, setSidePanelContent } = useSidePanel(); useWindowTitle("Zones"); - useFetchActions([zoneActions.fetch]); - let content = null; if ( @@ -41,7 +36,7 @@ const ZonesList = (): JSX.Element => { sidePanelContent={content} sidePanelTitle="Add AZ" > - {zonesCount > 0 && } + {zonesCount?.data && zonesCount.data > 0 && } ); }; diff --git a/src/app/zones/views/ZonesList/ZonesListHeader/ZonesListHeader.tsx b/src/app/zones/views/ZonesList/ZonesListHeader/ZonesListHeader.tsx index 5794e9eb55..13e075e39e 100644 --- a/src/app/zones/views/ZonesList/ZonesListHeader/ZonesListHeader.tsx +++ b/src/app/zones/views/ZonesList/ZonesListHeader/ZonesListHeader.tsx @@ -1,14 +1,11 @@ import { MainToolbar } from "@canonical/maas-react-components"; import { Button, Spinner } from "@canonical/react-components"; -import { useSelector } from "react-redux"; import ZonesListTitle from "./ZonesListTitle"; +import { useZoneCount } from "@/app/api/query/zones"; import ModelListSubtitle from "@/app/base/components/ModelListSubtitle"; -import { useFetchActions } from "@/app/base/hooks"; import type { SetSidePanelContent } from "@/app/base/side-panel-context"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; import { ZoneActionSidePanelViews } from "@/app/zones/constants"; const ZonesListHeader = ({ @@ -16,18 +13,15 @@ const ZonesListHeader = ({ }: { setSidePanelContent: SetSidePanelContent; }): JSX.Element => { - const zonesCount = useSelector(zoneSelectors.count); - const zonesLoaded = useSelector(zoneSelectors.loaded); - - useFetchActions([zoneActions.fetch]); + const zonesCount = useZoneCount(); return ( - {zonesLoaded ? ( - + {zonesCount.isSuccess ? ( + ) : ( )} diff --git a/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx b/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx index 6153808b3e..3423acc677 100644 --- a/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx +++ b/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx @@ -1,22 +1,17 @@ import { MainTable } from "@canonical/react-components"; -import { useSelector } from "react-redux"; import { Link } from "react-router-dom"; -import { useFetchActions } from "@/app/base/hooks"; +import { useZones } from "@/app/api/query/zones"; import urls from "@/app/base/urls"; import { FilterDevices } from "@/app/store/device/utils"; import { FilterMachines } from "@/app/store/machine/utils"; -import { zoneActions } from "@/app/store/zone"; -import zoneSelectors from "@/app/store/zone/selectors"; export enum TestIds { ZonesTable = "zones-table", } const ZonesListTable = (): JSX.Element => { - const zones = useSelector(zoneSelectors.all); - - useFetchActions([zoneActions.fetch]); + const zones = useZones(); const headers = [ { content: "Name", sortKey: "name" }, @@ -29,7 +24,8 @@ const ZonesListTable = (): JSX.Element => { className: "u-align--right", }, ]; - const rows = zones.map((zone) => { + + const rows = zones?.data?.map?.((zone) => { const devicesFilter = FilterDevices.filtersToQueryString({ zone: [zone.name], }); diff --git a/src/index.tsx b/src/index.tsx index b0ae66c945..6fbb8796e0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import SidePanelContextProvider from "./app/base/side-panel-context"; import { store, history } from "./redux-store"; import * as serviceWorker from "./serviceWorker"; +import { WebSocketProvider } from "@/app/base/websocket-context"; import "./scss/index.scss"; const queryClient = createQueryClient(); @@ -21,21 +22,23 @@ const queryClient = createQueryClient(); export const RootProviders = ({ children }: { children: JSX.Element }) => { return ( - - - {children} - - - + + + + {children} + + + + ); }; diff --git a/src/redux-store.ts b/src/redux-store.ts index 2f010794da..9329e33ef0 100644 --- a/src/redux-store.ts +++ b/src/redux-store.ts @@ -31,9 +31,8 @@ export const store = configureStore({ middleware, devTools: import.meta.env.NODE_ENV !== "production", }); - export const history = createReduxHistory(store); -const websocketClient = new WebSocketClient(); +export const websocketClient = new WebSocketClient(); sagaMiddleware.run(rootSaga, websocketClient); diff --git a/src/root-saga.ts b/src/root-saga.ts index 471719919a..cb94e41b5b 100644 --- a/src/root-saga.ts +++ b/src/root-saga.ts @@ -14,7 +14,6 @@ import { watchFetchLicenseKeys, watchUploadScript, watchAddMachineChassis, - watchZonesFetch, } from "./app/base/sagas"; import type { MessageHandler } from "@/app/base/sagas/actions"; @@ -35,6 +34,5 @@ export default function* rootSaga( watchFetchLicenseKeys(), watchUploadScript(), watchAddMachineChassis(), - watchZonesFetch(), ]); } diff --git a/src/testing/factories/index.ts b/src/testing/factories/index.ts index 4af43aa6d1..4a8f5431f0 100644 --- a/src/testing/factories/index.ts +++ b/src/testing/factories/index.ts @@ -225,3 +225,4 @@ export { vmHost, } from "./vmcluster"; export { zone } from "./zone"; +export { zonesGet } from "./response"; diff --git a/src/testing/factories/response.ts b/src/testing/factories/response.ts new file mode 100644 index 0000000000..63485667f2 --- /dev/null +++ b/src/testing/factories/response.ts @@ -0,0 +1,5 @@ +import { zone } from "./zone"; + +const zonesGet = () => [zone(), zone(), zone()]; + +export { zonesGet }; diff --git a/src/testing/factories/state.ts b/src/testing/factories/state.ts index 908820220f..5a00076c5b 100644 --- a/src/testing/factories/state.ts +++ b/src/testing/factories/state.ts @@ -662,7 +662,6 @@ export const zoneError = define({ export const zoneState = define({ errors: () => [], genericActions: zoneGenericActions, - items: () => [], modelActions: zoneModelActions, }); diff --git a/src/testing/utils.tsx b/src/testing/utils.tsx index e970cd7d22..7a2968c3ba 100644 --- a/src/testing/utils.tsx +++ b/src/testing/utils.tsx @@ -1,25 +1,34 @@ -import type { ReactNode } from "react"; +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"; +import type { MemoryHistory, MemoryHistoryOptions } from "history"; +import { createMemoryHistory } from "history"; import { produce } from "immer"; +import type { JsonBodyType } from "msw"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { Provider } from "react-redux"; import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { HistoryRouter } from "redux-first-history/rr6"; import type { MockStoreEnhanced } from "redux-mock-store"; import configureStore from "redux-mock-store"; +import type { ApiEndpointKey } from "@/app/api/base"; +import { API_ENDPOINTS, getFullApiUrl } from "@/app/api/base"; +import type { QueryModel } from "@/app/api/query-client"; import type { SidePanelContent, SidePanelSize, } from "@/app/base/side-panel-context"; import SidePanelContextProvider from "@/app/base/side-panel-context"; +import { WebSocketProvider } from "@/app/base/websocket-context"; import { ConfigNames } from "@/app/store/config/types"; import type { RootState } from "@/app/store/root/types"; +import * as factory from "@/testing/factories"; import { config as configFactory, configState as configStateFactory, @@ -43,20 +52,38 @@ import { zoneState as zoneStateFactory, } from "@/testing/factories"; -export const setupQueryClient = () => { +export type InitialData = Partial>; + +export const setupQueryClient = (queryData?: InitialData) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, + staleTime: Infinity, }, }, }); - beforeEach(() => { - queryClient.resetQueries(); - }); + + if (queryData) { + Object.entries(queryData).forEach(([key, value]) => { + queryClient.setQueryData([key], value); + }); + } + return queryClient; }; +const createBaseState = () => factory.rootState(); + +export const setupInitialState = (state?: Partial) => + structuredClone({ ...createBaseState(), ...state }); +export const setupInitialData = (queryData?: InitialData) => + queryData ?? createInitialData(); + +export const createInitialData = (): InitialData => ({ + zones: [], +}); + /** * Replace objects in an array with objects that have new values, given a match * criteria. @@ -111,52 +138,50 @@ export const getByTextContent = (text: string | RegExp): HTMLElement => { }); }; -type WrapperProps = { +interface WrapperProps { parentRoute?: string; routePattern?: string; state?: RootState; store?: MockStoreEnhanced; + queryData?: InitialData; sidePanelContent?: SidePanelContent; sidePanelSize?: SidePanelSize; -}; +} export const BrowserRouterWithProvider = ({ children, + queryData, parentRoute, routePattern, sidePanelContent, sidePanelSize, - state, store, }: WrapperProps & { children: React.ReactNode }): React.ReactNode => { - const getMockStore = (state: RootState) => { - const mockStore = configureStore(); - return mockStore(state); - }; - const route = ; return ( - - - - - {routePattern ? ( - - {parentRoute ? ( - {route} - ) : ( - route - )} - - ) : ( - children - )} - - - + + + + + + {routePattern ? ( + + {parentRoute ? ( + {route} + ) : ( + route + )} + + ) : ( + children + )} + + + + ); }; @@ -166,10 +191,6 @@ const WithMockStoreProvider = ({ state, store, }: WrapperProps & { children: React.ReactNode }) => { - const getMockStore = (state: RootState) => { - const mockStore = configureStore(); - return mockStore(state); - }; return ( @@ -179,31 +200,70 @@ const WithMockStoreProvider = ({ ); }; +interface EnhancedRenderResult extends RenderResult { + store: MockStoreEnhanced; +} +export interface WithRouterOptions extends RenderOptions, WrapperProps { + route?: string; + queryData?: Record; +} + +const getMockStore = (state = factory.rootState()) => { + const mockStore = configureStore(); + return mockStore(state); +}; + export const renderWithBrowserRouter = ( - ui: React.ReactElement, - options?: RenderOptions & - WrapperProps & { - route?: string; - } -): RenderResult => { - const { route, ...wrapperProps } = options || {}; - window.history.pushState({}, "", route); + ui: React.ReactNode, + options?: WithRouterOptions +) => { + let { + queryData = setupInitialData(), + state = rootStateFactory(), + store = getMockStore(state), + route = "/", + ...renderOptions + } = options || {}; + window.history.pushState({}, "Test page", route); - const rendered = render(ui, { - wrapper: (props) => ( - - ), - ...options, - }); + const Wrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); + }; + const rendered = render(ui, { wrapper: Wrapper, ...renderOptions }); + + const customRerender = ( + ui: React.ReactNode, + { state: newState }: { state?: WrapperProps["state"] } = {} + ) => { + if (newState) { + store = getMockStore({ ...state, ...newState }); + } + return render(ui, { + container: rendered.container, + wrapper: Wrapper, + ...renderOptions, + }); + }; return { + store, ...rendered, + rerender: customRerender, }; }; interface WithStoreRenderOptions extends RenderOptions { state?: RootState | ((stateDraft: RootState) => void); store?: WrapperProps["store"]; + queryData?: WrapperProps["queryData"]; } export const renderWithMockStore = ( @@ -212,17 +272,27 @@ export const renderWithMockStore = ( ): Omit & { rerender: (ui: React.ReactNode, newOptions?: WithStoreRenderOptions) => void; } => { - const { state, store, ...renderOptions } = options ?? {}; + const { state, store, queryData, ...renderOptions } = options ?? {}; const initialState = typeof state === "function" ? produce(rootStateFactory(), state) : state || rootStateFactory(); + const initialData = setupInitialData(queryData); + const queryClient = setupQueryClient(initialData); + const rendered = render(ui, { wrapper: (props) => ( - - - + + + + + ), ...renderOptions, }); @@ -237,6 +307,7 @@ export const renderWithMockStore = ( state && typeof newOptions?.state === "function" ? produce(state, newOptions.state) : newOptions?.state || state, + queryData: newOptions?.queryData || queryData, }), }; }; @@ -244,7 +315,7 @@ export const renderWithMockStore = ( export const getUrlParam: URLSearchParams["get"] = (param: string) => new URLSearchParams(window.location.search).get(param); -// Complete initial test state with all data loaded and no errors +// Complete initial test state with all queryData loaded and no errors export const getTestState = (): RootState => { const config = configFactory({ name: ConfigNames.SESSION_LENGTH, @@ -345,7 +416,9 @@ export const expectTooltipOnHover = async ( const generateWrapper = (store = configureStore()(rootStateFactory())) => ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); type Hook = Parameters[0]; @@ -362,24 +435,57 @@ export const renderHookWithQueryClient = (hook: Hook) => { const queryClient = setupQueryClient(); return renderHook(hook, { wrapper: ({ children }) => ( - - - {children} - - + + + + {children} + + + ), }); }; export const setupMockServer = () => { const server = setupServer(); + // Mock all existing endpoints by default with infinite loading state + const mockAllEndpoints = () => { + const endpoints = Object.values(API_ENDPOINTS); + endpoints.forEach((endpoint) => { + server.use( + http.get(getFullApiUrl(endpoint), () => { + return new Promise(() => {}); // Never resolve to simulate infinite loading + }) + ); + }); + }; beforeAll(() => server.listen()); + beforeEach(() => { + mockAllEndpoints(); + }); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); + const mockGet = (endpoint: ApiEndpointKey, response?: JsonBodyType) => { + server.use( + http.get(getFullApiUrl(endpoint), () => { + if (typeof response !== "undefined") { + return HttpResponse.json(response); + } + // Use factory.[endpoint]Get() when no response is provided + const factoryMethod = factory[`${endpoint}Get`]; + if (typeof factoryMethod === "function") { + return HttpResponse.json(factoryMethod()); + } + throw new Error(`No factory method found for endpoint: ${endpoint}`); + }) + ); + }; + return { server, + mockGet, http, HttpResponse, }; @@ -395,5 +501,48 @@ export { render, renderHook, within, + waitForElementToBeRemoved, } from "@testing-library/react"; export { default as userEvent } from "@testing-library/user-event"; + +interface WithHistoryRouterOptions + extends RenderOptions, + WrapperProps, + MemoryHistoryOptions { + history?: MemoryHistory; +} +export const renderWithHistoryRouter = ( + ui: React.ReactElement, + options?: WithHistoryRouterOptions +): EnhancedRenderResult & { history: MemoryHistory } => { + const { + state, + store: initialStore, + initialEntries, + ...renderOptions + } = options || {}; + const history = options?.history || createMemoryHistory({ initialEntries }); + + const getMockStore = (state: RootState) => { + const mockStore = configureStore(); + return mockStore(state); + }; + const store = initialStore ?? getMockStore(state || rootStateFactory()); + + const rendered = render( + + + + {ui} + + + , + renderOptions + ); + + return { + ...rendered, + store, + history, + }; +}; diff --git a/src/websocket-client.test.ts b/src/websocket-client.test.ts index eefe90cbd7..53d660adff 100644 --- a/src/websocket-client.test.ts +++ b/src/websocket-client.test.ts @@ -7,12 +7,12 @@ import { getCookie } from "@/app/utils"; vi.mock("@/app/utils"); const testAction = { - meta: { model: "test", method: "test" }, + meta: { model: "status", method: "ping" }, payload: { params: {}, }, type: "TEST_ACTION", -}; +} as const; describe("websocket client", () => { let client: WebSocketClient; diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 51a227f7b2..c07698d845 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -4,8 +4,199 @@ import ReconnectingWebSocket from "reconnecting-websocket"; import type { AnyObject } from "@/app/base/types"; import { getCookie } from "@/app/utils"; -// A model and method (e.g. 'users.list') -export type WebSocketEndpoint = string; +const WebSocketEndpoints = { + bootresource: [ + "delete_image", + "fetch", + "poll", + "save_other", + "save_ubuntu", + "save_ubuntu_core", + "stop_import", + ], + config: ["bulk_update", "get", "list", "update"], + controller: [ + "action", + "check_images", + "create", + "get", + "get_latest_failed_testing_script_results", + "get_summary_xml", + "get_summary_yaml", + "list", + "set_active", + "set_script_result_suppressed", + "set_script_result_unsuppressed", + "update", + "update_interface", + ], + device: [ + "action", + "create", + "create_interface", + "create_physical", + "delete_interface", + "get", + "link_subnet", + "list", + "set_active", + "unlink_subnet", + "update", + "update_interface", + ], + dhcpsnippet: ["create", "delete", "get", "list", "revert", "update"], + discovery: ["clear", "delete_by_mac_and_ip", "get", "list"], + domain: [ + "create", + "create_address_record", + "create_dnsdata", + "create_dnsresource", + "delete", + "delete_address_record", + "delete_dnsdata", + "delete_dnsresource", + "get", + "list", + "set_active", + "set_default", + "update", + "update_address_record", + "update_dnsdata", + "update_dnsresource", + ], + event: ["clear", "list"], + fabric: ["create", "delete", "get", "list", "set_active", "update"], + general: [ + "architectures", + "bond_options", + "components_to_disable", + "default_min_hwe_kernel", + "device_actions", + "generate_client_certificate", + "hwe_kernels", + "known_architectures", + "known_boot_architectures", + "machine_actions", + "min_hwe_kernels", + "osinfo", + "pockets_to_disable", + "power_types", + "rack_controller_actions", + "random_hostname", + "region_and_rack_controller_actions", + "region_controller_actions", + "release_options", + "target_version", + "tls_certificate", + "vault_enabled", + "version", + ], + iprange: ["create", "delete", "get", "list", "update"], + machine: [ + "action", + "apply_storage_layout", + "check_power", + "count", + "create", + "create_bcache", + "create_bond", + "create_bridge", + "create_cache_set", + "create_logical_volume", + "create_partition", + "create_physical", + "create_raid", + "create_vlan", + "create_vmfs_datastore", + "create_volume_group", + "default_user", + "delete_cache_set", + "delete_disk", + "delete_filesystem", + "delete_interface", + "delete_partition", + "delete_vmfs_datastore", + "delete_volume_group", + "filter_groups", + "filter_options", + "get", + "get_latest_failed_testing_script_results", + "get_summary_xml", + "get_summary_yaml", + "get_workload_annotations", + "link_subnet", + "list", + "list_ids", + "mount_special", + "set_active", + "set_boot_disk", + "set_script_result_suppressed", + "set_script_result_unsuppressed", + "set_workload_annotations", + "unlink_subnet", + "unmount_special", + "unsubscribe", + "update", + "update_disk", + "update_filesystem", + "update_interface", + "update_vmfs_datastore", + ], + node_device: ["delete", "list"], + node_result: ["clear", "get", "get_history", "get_result_data", "list"], + packagerepository: ["create", "delete", "get", "list", "update"], + pod: [ + "compose", + "create", + "delete", + "get", + "get_projects", + "list", + "refresh", + "set_active", + "update", + ], + resourcepool: ["create", "delete", "get", "list", "update"], + script: ["delete", "get_script", "list"], + service: ["get", "list", "set_active"], + space: ["create", "delete", "get", "list", "set_active", "update"], + sshkey: ["create", "delete", "get", "import_keys", "list"], + sslkey: ["create", "delete", "get", "list"], + staticroute: ["create", "delete", "get", "list", "update"], + status: ["ping"], + subnet: ["create", "delete", "get", "list", "scan", "set_active", "update"], + tag: ["create", "delete", "get", "list", "update"], + token: ["create", "delete", "get", "list", "update"], + user: [ + "admin_change_password", + "auth_user", + "change_password", + "create", + "delete", + "get", + "list", + "mark_intro_complete", + "update", + ], + vlan: [ + "configure_dhcp", + "create", + "delete", + "get", + "list", + "set_active", + "update", + ], + vmcluster: ["delete", "get", "list", "list_by_physical_cluster", "update"], + zone: ["create", "delete", "get", "list", "set_active", "update"], +} as const; + +export type WebSocketEndpointModel = keyof typeof WebSocketEndpoints; +export type WebSocketEndpointMethod = + (typeof WebSocketEndpoints)[WebSocketEndpointModel][number]; + +export type WebSocketEndpoint = + `${WebSocketEndpointModel}.${WebSocketEndpointMethod}`; // Message types defined by MAAS websocket API. export enum WebSocketMessageType { @@ -81,9 +272,9 @@ export type WebSocketAction

= PayloadAction< // key of a model in order to track a its loading/success/error states. identifier?: number | string; // The endpoint method e.g. "list". - method?: string; + method: WebSocketEndpointMethod; // The endpoint model e.g. "machine". - model: string; + model: WebSocketEndpointModel; // Whether the request should be fetched every time. nocache?: boolean; // Whether the request should be polled.