From 9774222152c44bf829f03429dec6fe732e757221 Mon Sep 17 00:00:00 2001 From: Nick De Villiers Date: Wed, 15 May 2024 11:48:37 +0200 Subject: [PATCH] feat(reservedip): Add reserved ips to redux store MAASENG-2983 (#5427) - Created types for reserved IPs - Created store slice for reserved IPs - Created reducers for reserved IPs - Created selectors for reserved IPs - Integrated reserved ip table with store - Removed legacy `StaticDHCPLease` type - Refactored `useStaticDHCPLease` to `useReservedIps`, integrated with store - Created util to get node URL from system ID and node type - Deleted legacy `staticDHCPLease` factory - Created `reservedIp` and `reservedIpState` factories - Created `ReservedIp` type - Created `ReservedIpNodeSummary` type --- src/__snapshots__/root-reducer.test.ts.snap | 8 + src/app/store/reservedip/action.test.ts | 89 ++++++++ src/app/store/reservedip/index.ts | 1 + src/app/store/reservedip/reducers.test.ts | 215 ++++++++++++++++++ src/app/store/reservedip/selectors.test.ts | 84 +++++++ src/app/store/reservedip/selectors.ts | 35 +++ src/app/store/reservedip/slice.ts | 79 +++++++ src/app/store/reservedip/types/actions.ts | 17 ++ src/app/store/reservedip/types/base.ts | 23 ++ src/app/store/reservedip/types/enum.ts | 4 + src/app/store/reservedip/types/index.ts | 3 + src/app/store/reservedip/utils.test.ts | 31 +++ src/app/store/reservedip/utils.ts | 15 ++ src/app/store/root/types.ts | 3 + src/app/store/subnet/hooks.ts | 30 +-- src/app/store/subnet/types/base.ts | 18 +- .../DeleteDHCPLease/DeleteDHCPLease.test.tsx | 62 ++++- .../DeleteDHCPLease/DeleteDHCPLease.tsx | 33 ++- .../ReserveDHCPLease.test.tsx | 49 ++++ .../ReserveDHCPLease/ReserveDHCPLease.tsx | 48 +++- .../StaticDHCPLease/StaticDHCPLease.tsx | 14 +- .../StaticDHCPTable/StaticDHCPTable.test.tsx | 12 +- .../StaticDHCPTable/StaticDHCPTable.tsx | 69 ++++-- .../SubnetActionForms/SubnetActionForms.tsx | 4 +- .../subnets/views/SubnetDetails/constants.ts | 8 +- src/app/subnets/views/SubnetDetails/types.ts | 3 +- src/root-reducer.ts | 2 + src/testing/factories/index.ts | 2 + src/testing/factories/reservedip.ts | 28 +++ src/testing/factories/state.ts | 7 + src/testing/factories/subnet.ts | 16 +- 31 files changed, 904 insertions(+), 108 deletions(-) create mode 100644 src/app/store/reservedip/action.test.ts create mode 100644 src/app/store/reservedip/index.ts create mode 100644 src/app/store/reservedip/reducers.test.ts create mode 100644 src/app/store/reservedip/selectors.test.ts create mode 100644 src/app/store/reservedip/selectors.ts create mode 100644 src/app/store/reservedip/slice.ts create mode 100644 src/app/store/reservedip/types/actions.ts create mode 100644 src/app/store/reservedip/types/base.ts create mode 100644 src/app/store/reservedip/types/enum.ts create mode 100644 src/app/store/reservedip/types/index.ts create mode 100644 src/app/store/reservedip/utils.test.ts create mode 100644 src/app/store/reservedip/utils.ts create mode 100644 src/testing/factories/reservedip.ts diff --git a/src/__snapshots__/root-reducer.test.ts.snap b/src/__snapshots__/root-reducer.test.ts.snap index 41be873428..3fed244ef7 100644 --- a/src/__snapshots__/root-reducer.test.ts.snap +++ b/src/__snapshots__/root-reducer.test.ts.snap @@ -266,6 +266,14 @@ exports[`rootReducer > should reset app to initial state on LOGOUT_SUCCESS, exce "saving": false, "statuses": {}, }, + "reservedip": { + "errors": null, + "items": [], + "loaded": false, + "loading": false, + "saved": false, + "saving": false, + }, "resourcepool": { "errors": null, "items": [], diff --git a/src/app/store/reservedip/action.test.ts b/src/app/store/reservedip/action.test.ts new file mode 100644 index 0000000000..792adcaec5 --- /dev/null +++ b/src/app/store/reservedip/action.test.ts @@ -0,0 +1,89 @@ +import { actions } from "./slice"; + +it("can create an action for fetching reserved IPs", () => { + expect(actions.fetch()).toEqual({ + type: "reservedip/fetch", + meta: { + model: "reservedip", + method: "list", + }, + payload: null, + }); +}); + +it("can create an action for creating a reserved IP", () => { + const params = { + comment: "It's an IP address", + ip: "192.168.0.2", + mac_address: "00:00:00:00:00:00", + subnet: 1, + }; + + expect(actions.create(params)).toEqual({ + type: "reservedip/create", + meta: { + model: "reservedip", + method: "create", + }, + payload: { + params, + }, + }); +}); + +it("can create an action for deleting a reserved IP", () => { + expect(actions.delete(1)).toEqual({ + type: "reservedip/delete", + meta: { + model: "reservedip", + method: "delete", + }, + payload: { + params: { + id: 1, + }, + }, + }); +}); + +it("can create an action for updating a reserved IP", () => { + const params = { + id: 1, + comment: "It's an IP address", + ip: "192.168.0.2", + mac_address: "00:00:00:00:00:00", + subnet: 1, + }; + + expect(actions.update(params)).toEqual({ + type: "reservedip/update", + meta: { + model: "reservedip", + method: "update", + }, + payload: { + params, + }, + }); +}); + +it("can clean up", () => { + expect(actions.cleanup()).toEqual({ + type: "reservedip/cleanup", + }); +}); + +it("can create an action for getting a reserved IP", () => { + expect(actions.get(1)).toEqual({ + type: "reservedip/get", + meta: { + model: "reservedip", + method: "get", + }, + payload: { + params: { + id: 1, + }, + }, + }); +}); diff --git a/src/app/store/reservedip/index.ts b/src/app/store/reservedip/index.ts new file mode 100644 index 0000000000..a617beae01 --- /dev/null +++ b/src/app/store/reservedip/index.ts @@ -0,0 +1 @@ +export { default, actions as reservedIpActions } from "./slice"; diff --git a/src/app/store/reservedip/reducers.test.ts b/src/app/store/reservedip/reducers.test.ts new file mode 100644 index 0000000000..79b4eddd66 --- /dev/null +++ b/src/app/store/reservedip/reducers.test.ts @@ -0,0 +1,215 @@ +import reducers, { actions } from "./slice"; + +import * as factory from "@/testing/factories"; + +it("should return the initial state", () => { + expect(reducers(undefined, { type: "" })).toEqual( + factory.reservedIpState({ + errors: null, + loading: false, + loaded: false, + items: [], + saved: false, + saving: false, + }) + ); +}); + +it("should correctly reduce cleanup", () => { + const initialState = factory.reservedIpState({ + errors: { key: "Key already exists" }, + saved: true, + saving: true, + }); + expect(reducers(initialState, actions.cleanup())).toEqual( + factory.reservedIpState({ + errors: null, + saved: false, + saving: false, + }) + ); +}); + +describe("fetch reducers", () => { + it("should correctly reduce fetchStart", () => { + const initialState = factory.reservedIpState({ loading: false }); + expect(reducers(initialState, actions.fetchStart())).toEqual( + factory.reservedIpState({ + loading: true, + }) + ); + }); + + it("should correctly reduce fetchSuccess", () => { + const initialState = factory.reservedIpState({ + loading: true, + loaded: false, + items: [], + }); + const items = [factory.reservedIp(), factory.reservedIp()]; + expect(reducers(initialState, actions.fetchSuccess(items))).toEqual( + factory.reservedIpState({ + loading: false, + loaded: true, + items, + }) + ); + }); + + it("should correctly reduce fetchError", () => { + const initialState = factory.reservedIpState({ + loading: true, + loaded: false, + items: [], + }); + expect(reducers(initialState, actions.fetchError("Error"))).toEqual( + factory.reservedIpState({ + loading: false, + errors: "Error", + }) + ); + }); +}); + +describe("create reducers", () => { + it("should correctly reduce createStart", () => { + const initialState = factory.reservedIpState({ saving: false }); + expect(reducers(initialState, actions.createStart())).toEqual( + factory.reservedIpState({ + saving: true, + }) + ); + }); + + it("should correctly reduce createSuccess", () => { + const initialState = factory.reservedIpState({ + saving: true, + saved: false, + }); + + const reservedIp = factory.reservedIp(); + expect( + reducers( + initialState, + actions.createSuccess(factory.reservedIp(reservedIp)) + ) + ).toEqual( + factory.reservedIpState({ + saving: false, + saved: true, + items: [reservedIp], + }) + ); + }); + + it("should correctly reduce createError", () => { + const initialState = factory.reservedIpState({ + saving: true, + saved: false, + }); + expect(reducers(initialState, actions.createError("Error"))).toEqual( + factory.reservedIpState({ + saving: false, + errors: "Error", + }) + ); + }); + + it("should correctly reduce createNotify", () => { + const items = [factory.reservedIp(), factory.reservedIp()]; + const initialState = factory.reservedIpState({ + items: [items[0]], + }); + expect(reducers(initialState, actions.createNotify(items[1]))).toEqual( + factory.reservedIpState({ + items, + }) + ); + }); +}); + +describe("delete reducers", () => { + it("should correctly reduce deleteStart", () => { + const initialState = factory.reservedIpState({ saving: false }); + expect(reducers(initialState, actions.deleteStart())).toEqual( + factory.reservedIpState({ + saving: true, + }) + ); + }); + + it("should correctly reduce deleteSuccess", () => { + const initialState = factory.reservedIpState({ + saving: true, + saved: false, + }); + expect(reducers(initialState, actions.deleteSuccess({ id: 1 }))).toEqual( + factory.reservedIpState({ + saving: false, + saved: true, + }) + ); + }); + + it("should correctly reduce deleteError", () => { + const initialState = factory.reservedIpState({ + saving: true, + saved: false, + }); + expect(reducers(initialState, actions.deleteError("Error"))).toEqual( + factory.reservedIpState({ + saving: false, + errors: "Error", + }) + ); + }); + + it("should correctly reduce deleteNotify", () => { + const items = [factory.reservedIp(), factory.reservedIp()]; + const initialState = factory.reservedIpState({ + items, + }); + expect( + reducers(initialState, actions.deleteNotify(items[0].id)).items + ).toEqual([items[1]]); + }); +}); + +describe("get reducers", () => { + it("should correctly reduce getStart", () => { + const initialState = factory.reservedIpState({ items: [], loading: false }); + expect(reducers(initialState, actions.getStart())).toEqual( + factory.reservedIpState({ + loading: true, + }) + ); + }); + + it("should correctly reduce getSuccess", () => { + const newReservedIp = factory.reservedIp(); + const reservedIpState = factory.reservedIpState({ + items: [], + loading: true, + }); + expect( + reducers(reservedIpState, actions.getSuccess(newReservedIp)) + ).toEqual( + factory.reservedIpState({ + items: [newReservedIp], + loading: false, + }) + ); + }); + + it("should correctly reduce getError", () => { + const initialState = factory.reservedIpState({ + loading: true, + }); + expect(reducers(initialState, actions.getError("Error"))).toEqual( + factory.reservedIpState({ + loading: false, + errors: "Error", + }) + ); + }); +}); diff --git a/src/app/store/reservedip/selectors.test.ts b/src/app/store/reservedip/selectors.test.ts new file mode 100644 index 0000000000..0dabb99b41 --- /dev/null +++ b/src/app/store/reservedip/selectors.test.ts @@ -0,0 +1,84 @@ +import reservedIp from "./selectors"; + +import * as factory from "@/testing/factories"; + +it("returns list of all reserved IPs", () => { + const items = [factory.reservedIp(), factory.reservedIp()]; + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + items, + }), + }); + expect(reservedIp.all(state)).toStrictEqual(items); +}); + +it("returns reservedip loading state", () => { + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + loading: false, + }), + }); + expect(reservedIp.loading(state)).toStrictEqual(false); +}); + +it("returns reservedip loaded state", () => { + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + loaded: true, + }), + }); + expect(reservedIp.loaded(state)).toStrictEqual(true); +}); + +it("returns reservedip error state", () => { + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + errors: "Unable to list reserved IPs.", + }), + }); + expect(reservedIp.errors(state)).toEqual("Unable to list reserved IPs."); +}); + +it("returns reservedip saving state", () => { + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + saving: true, + }), + }); + expect(reservedIp.saving(state)).toStrictEqual(true); +}); + +it("returns reservedip saved state", () => { + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + saved: true, + }), + }); + expect(reservedIp.saved(state)).toStrictEqual(true); +}); + +it("returns reserved IPs that are in a subnet", () => { + const subnet = factory.subnet(); + const subnet2 = factory.subnet(); + const items = [ + factory.reservedIp({ subnet: subnet.id }), + factory.reservedIp({ subnet: subnet.id }), + factory.reservedIp({ subnet: subnet2.id }), + ]; + const state = factory.rootState({ + reservedip: factory.reservedIpState({ + items, + }), + subnet: factory.subnetState({ + items: [subnet, subnet2], + }), + }); + expect(reservedIp.getBySubnet(state, subnet.id)).toStrictEqual( + items.slice(0, 2) + ); +}); + +it("handles an undefined subnet", () => { + const state = factory.rootState(); + expect(reservedIp.getBySubnet(state, undefined)).toStrictEqual([]); +}); diff --git a/src/app/store/reservedip/selectors.ts b/src/app/store/reservedip/selectors.ts new file mode 100644 index 0000000000..7c6b30c187 --- /dev/null +++ b/src/app/store/reservedip/selectors.ts @@ -0,0 +1,35 @@ +import { createSelector } from "@reduxjs/toolkit"; + +import type { RootState } from "../root/types"; + +import type { ReservedIp, ReservedIpState } from "./types"; +import { ReservedIpMeta } from "./types/enum"; + +import { generateBaseSelectors } from "@/app/store/utils"; +import { isId } from "@/app/utils"; + +const defaultSelectors = generateBaseSelectors< + ReservedIpState, + ReservedIp, + ReservedIpMeta.PK +>(ReservedIpMeta.MODEL, ReservedIpMeta.PK); + +const getBySubnet = createSelector( + [ + defaultSelectors.all, + (_state: RootState, id: ReservedIp["subnet"] | undefined) => id, + ], + (reservedIps, id) => { + if (!isId(id)) { + return []; + } + return reservedIps.filter(({ subnet }) => subnet === id); + } +); + +const selectors = { + ...defaultSelectors, + getBySubnet, +}; + +export default selectors; diff --git a/src/app/store/reservedip/slice.ts b/src/app/store/reservedip/slice.ts new file mode 100644 index 0000000000..1dfc2f0fb6 --- /dev/null +++ b/src/app/store/reservedip/slice.ts @@ -0,0 +1,79 @@ +import type { PayloadAction } from "@reduxjs/toolkit"; +import { createSlice } from "@reduxjs/toolkit"; + +import type { + CreateParams, + ReservedIp, + ReservedIpState, + UpdateParams, +} from "./types"; +import type { DeleteParams } from "./types/actions"; +import { ReservedIpMeta } from "./types/enum"; + +import type { GenericItemMeta } from "@/app/store/utils/slice"; +import { + generateCommonReducers, + generateGetReducers, + genericInitialState, +} from "@/app/store/utils/slice"; + +const commonReducers = generateCommonReducers< + ReservedIpState, + ReservedIpMeta.PK, + CreateParams, + UpdateParams +>({ modelName: ReservedIpMeta.MODEL, primaryKey: ReservedIpMeta.PK }); + +const reservedIpSlice = createSlice({ + name: ReservedIpMeta.MODEL, + initialState: genericInitialState as ReservedIpState, + reducers: { + ...commonReducers, + ...generateGetReducers({ + modelName: ReservedIpMeta.MODEL, + primaryKey: ReservedIpMeta.PK, + }), + createSuccess: ( + state: ReservedIpState, + action: PayloadAction + ) => { + commonReducers.createSuccess(state); + const item = action.payload; + const index = (state.items as ReservedIp[]).findIndex( + (draftItem: ReservedIp) => + draftItem[ReservedIpMeta.PK] === item[ReservedIpMeta.PK] + ); + if (index !== -1) { + state.items[index] = item; + } else { + (state.items as ReservedIp[]).push(item); + } + }, + deleteSuccess: { + prepare: (params: DeleteParams) => ({ + meta: { + item: params, + }, + payload: null, + }), + reducer: ( + state: ReservedIpState, + action: PayloadAction> + ) => { + commonReducers.deleteSuccess(state); + const id = action.meta.item.id; + const index = (state.items as ReservedIp[]).findIndex( + (draftItem: ReservedIp) => draftItem[ReservedIpMeta.PK] === id + ); + + if (index !== -1) { + (state.items as ReservedIp[]).splice(index, 1); + } + }, + }, + }, +}); + +export const { actions } = reservedIpSlice; + +export default reservedIpSlice.reducer; diff --git a/src/app/store/reservedip/types/actions.ts b/src/app/store/reservedip/types/actions.ts new file mode 100644 index 0000000000..5447795348 --- /dev/null +++ b/src/app/store/reservedip/types/actions.ts @@ -0,0 +1,17 @@ +import type { ReservedIp } from "./base"; +import type { ReservedIpMeta } from "./enum"; + +export type CreateParams = { + ip: ReservedIp["ip"]; + mac_address?: ReservedIp["mac_address"]; + comment?: ReservedIp["comment"]; + subnet?: ReservedIp["subnet"]; +}; + +export type UpdateParams = Partial & { + [ReservedIpMeta.PK]: ReservedIp[ReservedIpMeta.PK]; +}; + +export type DeleteParams = { + [ReservedIpMeta.PK]: ReservedIp[ReservedIpMeta.PK]; +}; diff --git a/src/app/store/reservedip/types/base.ts b/src/app/store/reservedip/types/base.ts new file mode 100644 index 0000000000..5f00442172 --- /dev/null +++ b/src/app/store/reservedip/types/base.ts @@ -0,0 +1,23 @@ +import type { APIError } from "@/app/base/types"; +import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; +import type { TimestampedModel } from "@/app/store/types/model"; +import type { NetworkInterface, Node, NodeType } from "@/app/store/types/node"; +import type { GenericState } from "@/app/store/types/state"; + +export type ReservedIp = TimestampedModel & { + ip: string; + mac_address?: string; + comment?: string; + subnet: Subnet[SubnetMeta.PK]; + node_summary?: ReservedIpNodeSummary; +}; + +export type ReservedIpNodeSummary = { + fqdn: Node["fqdn"]; + hostname: Node["hostname"]; + node_type: NodeType; + system_id: Node["system_id"]; + via?: NetworkInterface["name"]; +}; + +export type ReservedIpState = GenericState; diff --git a/src/app/store/reservedip/types/enum.ts b/src/app/store/reservedip/types/enum.ts new file mode 100644 index 0000000000..a6b3b76e5f --- /dev/null +++ b/src/app/store/reservedip/types/enum.ts @@ -0,0 +1,4 @@ +export enum ReservedIpMeta { + MODEL = "reservedip", + PK = "id", +} diff --git a/src/app/store/reservedip/types/index.ts b/src/app/store/reservedip/types/index.ts new file mode 100644 index 0000000000..d6b3cd1126 --- /dev/null +++ b/src/app/store/reservedip/types/index.ts @@ -0,0 +1,3 @@ +export type { CreateParams, UpdateParams } from "./actions"; + +export type { ReservedIp, ReservedIpState } from "./base"; diff --git a/src/app/store/reservedip/utils.test.ts b/src/app/store/reservedip/utils.test.ts new file mode 100644 index 0000000000..95cce72ca5 --- /dev/null +++ b/src/app/store/reservedip/utils.test.ts @@ -0,0 +1,31 @@ +import { NodeType } from "../types/node"; + +import { getNodeUrl } from "./utils"; + +describe("getNodeUrl", () => { + it("gets the URL for a machine", () => { + expect(getNodeUrl(NodeType.MACHINE, "abc123")).toBe("/machine/abc123"); + }); + + it("gets the URL for a device", () => { + expect(getNodeUrl(NodeType.DEVICE, "abc123")).toBe("/device/abc123"); + }); + + it("gets the URL for a rack controller", () => { + expect(getNodeUrl(NodeType.RACK_CONTROLLER, "abc123")).toBe( + "/controller/abc123" + ); + }); + + it("gets the URL for a region controller", () => { + expect(getNodeUrl(NodeType.REGION_CONTROLLER, "abc123")).toBe( + "/controller/abc123" + ); + }); + + it("gets the URL for a region + rack controller", () => { + expect(getNodeUrl(NodeType.REGION_AND_RACK_CONTROLLER, "abc123")).toBe( + "/controller/abc123" + ); + }); +}); diff --git a/src/app/store/reservedip/utils.ts b/src/app/store/reservedip/utils.ts new file mode 100644 index 0000000000..0aca359614 --- /dev/null +++ b/src/app/store/reservedip/utils.ts @@ -0,0 +1,15 @@ +import type { Node } from "../types/node"; +import { NodeType } from "../types/node"; + +import urls from "@/app/base/urls"; + +export const getNodeUrl = (type: NodeType, system_id: Node["system_id"]) => { + switch (type) { + case NodeType.MACHINE: + return urls.machines.machine.index({ id: system_id }); + case NodeType.DEVICE: + return urls.devices.device.index({ id: system_id }); + default: + return urls.controllers.controller.index({ id: system_id }); + } +}; diff --git a/src/app/store/root/types.ts b/src/app/store/root/types.ts index e729e5373c..ad2ed8f87e 100644 --- a/src/app/store/root/types.ts +++ b/src/app/store/root/types.ts @@ -1,5 +1,7 @@ import type { RouterState } from "redux-first-history"; +import type { ReservedIpState } from "../reservedip/types"; +import type { ReservedIpMeta } from "../reservedip/types/enum"; import type { VMClusterMeta, VMClusterState } from "../vmcluster/types"; import type { @@ -93,6 +95,7 @@ export type RootState = { [NotificationMeta.MODEL]: NotificationState; [PackageRepositoryMeta.MODEL]: PackageRepositoryState; [PodMeta.MODEL]: PodState; + [ReservedIpMeta.MODEL]: ReservedIpState; [ResourcePoolMeta.MODEL]: ResourcePoolState; router: RouterState; [ScriptResultMeta.MODEL]: ScriptResultState; diff --git a/src/app/store/subnet/hooks.ts b/src/app/store/subnet/hooks.ts index d3d1dbbe10..edc08710c7 100644 --- a/src/app/store/subnet/hooks.ts +++ b/src/app/store/subnet/hooks.ts @@ -1,15 +1,15 @@ -import { useEffect, useState } from "react"; - import { useSelector } from "react-redux"; +import { reservedIpActions } from "../reservedip"; + import { getHasIPAddresses } from "./utils"; import { useFetchActions } from "@/app/base/hooks"; +import reservedIpSelectors from "@/app/store/reservedip/selectors"; import type { RootState } from "@/app/store/root/types"; import { subnetActions } from "@/app/store/subnet"; import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; -import type { StaticDHCPLease } from "@/app/store/subnet/types/base"; import { vlanActions } from "@/app/store/vlan"; import vlanSelectors from "@/app/store/vlan/selectors"; @@ -47,26 +47,12 @@ export const useCanBeDeleted = (id?: Subnet[SubnetMeta.PK] | null): boolean => { return !isDHCPEnabled || (isDHCPEnabled && !getHasIPAddresses(subnet)); }; -export const useStaticDHCPLeases = ( - _subnetId: Subnet[SubnetMeta.PK] | null -) => { - const [staticDHCPLeases, setStaticDHCPLeases] = useState( - [] +export const useReservedIps = (subnetId: Subnet[SubnetMeta.PK]) => { + const reservedIps = useSelector((state: RootState) => + reservedIpSelectors.getBySubnet(state, subnetId) ); - // TODO: replace mock implementation with API call https://warthogs.atlassian.net/browse/MAASENG-2983 - const fetchStaticDHCPLeases = async () => { - if (import.meta.env.VITE_APP_STATIC_IPS_ENABLED === "true") { - const { array } = await import("cooky-cutter"); - const { staticDHCPLease } = await import("@/testing/factories/subnet"); - return array(staticDHCPLease, 5)(); - } - return []; - }; - - useEffect(() => { - fetchStaticDHCPLeases().then(setStaticDHCPLeases); - }, []); + useFetchActions([reservedIpActions.fetch]); - return staticDHCPLeases; + return reservedIps; }; diff --git a/src/app/store/subnet/types/base.ts b/src/app/store/subnet/types/base.ts index 4cf7870a3f..3bcdf93f0a 100644 --- a/src/app/store/subnet/types/base.ts +++ b/src/app/store/subnet/types/base.ts @@ -9,12 +9,7 @@ import type { TimestampedModel, TimestampFields, } from "@/app/store/types/model"; -import type { - NetworkInterface, - Node, - NodeType, - SimpleNode, -} from "@/app/store/types/node"; +import type { NetworkInterface, Node, NodeType } from "@/app/store/types/node"; import type { EventError, GenericState } from "@/app/store/types/state"; import type { User } from "@/app/store/user/types"; import type { VLAN } from "@/app/store/vlan/types"; @@ -125,14 +120,3 @@ export type SubnetState = GenericState & { eventErrors: EventError[]; statuses: SubnetStatuses; }; - -export type StaticDHCPLease = { - id: number; - comment: string | null; - ip_address: string; - mac_address: string; - interface: string | null; - node: SimpleNode | null; - usage?: string | null; - owner: string; -}; diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.test.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.test.tsx index 0ec4a1d2a4..44b906e9af 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.test.tsx @@ -1,20 +1,70 @@ +import configureStore from "redux-mock-store"; + import DeleteDHCPLease from "./DeleteDHCPLease"; -import { renderWithBrowserRouter, screen } from "@/testing/utils"; +import type { RootState } from "@/app/store/root/types"; +import * as factory from "@/testing/factories"; +import { + getTestState, + renderWithBrowserRouter, + screen, + userEvent, +} from "@/testing/utils"; + +let state: RootState; + +const mockStore = configureStore(); + +beforeEach(() => { + state = getTestState(); + state.subnet = factory.subnetState({ + loading: false, + loaded: true, + items: [factory.subnet({ id: 1, cidr: "10.0.0.0/24" })], + }); + state.reservedip = factory.reservedIpState({ + loading: false, + loaded: true, + items: [factory.reservedIp({ id: 1, ip: "10.0.0.2" })], + }); +}); it("renders a delete confirmation form", () => { renderWithBrowserRouter( - + , + { state } ); expect( screen.getByRole("form", { name: "Delete static IP" }) ).toBeInTheDocument(); expect( screen.getByText( - "Are you sure you want to delete this static IP? This action is permanent and can not be undone." + `Are you sure you want to delete ${state.reservedip.items[0].ip}? This action is permanent and cannot be undone.` ) ).toBeInTheDocument(); }); + +it("dispatches an action to delete a reserved IP", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { store } + ); + + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect( + store.getActions().find((action) => action.type === "reservedip/delete") + ).toEqual({ + meta: { + method: "delete", + model: "reservedip", + }, + payload: { + params: { + id: 1, + }, + }, + type: "reservedip/delete", + }); +}); diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.tsx index d39dd9179b..574e951886 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease/DeleteDHCPLease.tsx @@ -1,20 +1,41 @@ +import { useDispatch, useSelector } from "react-redux"; + import type { SubnetActionProps } from "../../types"; import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { reservedIpActions } from "@/app/store/reservedip"; +import reservedIpSelectors from "@/app/store/reservedip/selectors"; +import type { RootState } from "@/app/store/root/types"; + +type Props = Pick; +const DeleteDHCPLease = ({ setSidePanelContent, reservedIpId }: Props) => { + const dispatch = useDispatch(); + const errors = useSelector(reservedIpSelectors.errors); + const saving = useSelector(reservedIpSelectors.saving); + const saved = useSelector(reservedIpSelectors.saved); + + const reservedIp = useSelector((state: RootState) => + reservedIpSelectors.getById(state, reservedIpId) + ); -type Props = Pick; -const DeleteDHCPLease = ({ setSidePanelContent, macAddress }: Props) => { - if (!macAddress) return null; const handleClose = () => setSidePanelContent(null); - // TODO: Implement onSubmit function and passing IDs when API supports it. - // https://warthogs.atlassian.net/browse/MAASENG-2983 + + if (!reservedIpId) return null; + return ( {}} + onSubmit={() => { + dispatch(reservedIpActions.delete(reservedIpId)); + }} + onSuccess={handleClose} + saved={saved} + saving={saving} /> ); }; diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx index b900fee4e5..08d6bf3c49 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx @@ -1,3 +1,5 @@ +import configureStore from "redux-mock-store"; + import ReserveDHCPLease from "./ReserveDHCPLease"; import type { RootState } from "@/app/store/root/types"; @@ -12,6 +14,8 @@ import { const { getComputedStyle } = window; let state: RootState; +const mockStore = configureStore(); + beforeAll(() => { // getComputedStyle is not implemeneted in jsdom, so we need to do this. window.getComputedStyle = (elt) => getComputedStyle(elt); @@ -77,3 +81,48 @@ it("closes the side panel when the cancel button is clicked", async () => { expect(setSidePanelContent).toHaveBeenCalledWith(null); }); + +it("dispatches an action to create a reserved IP", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { store } + ); + + await userEvent.type( + screen.getByRole("textbox", { name: "IP address" }), + "69" + ); + + await userEvent.type( + screen.getByRole("textbox", { name: "MAC address" }), + "FF:FF:FF:FF:FF:FF" + ); + + await userEvent.type( + screen.getByRole("textbox", { name: "Comment" }), + "bla bla bla" + ); + + await userEvent.click( + screen.getByRole("button", { name: "Reserve static DHCP lease" }) + ); + + expect( + store.getActions().find((action) => action.type === "reservedip/create") + ).toEqual({ + meta: { + method: "create", + model: "reservedip", + }, + payload: { + params: { + subnet: 1, + ip: "10.0.0.69", + mac_address: "FF:FF:FF:FF:FF:FF", + comment: "bla bla bla", + }, + }, + type: "reservedip/create", + }); +}); diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx index 4841c47825..392804cce2 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx @@ -1,5 +1,7 @@ +import { useCallback } from "react"; + import { Spinner } from "@canonical/react-components"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import * as Yup from "yup"; import type { SubnetActionProps } from "../../types"; @@ -9,6 +11,8 @@ import FormikForm from "@/app/base/components/FormikForm"; import MacAddressField from "@/app/base/components/MacAddressField"; import PrefixedIpInput from "@/app/base/components/PrefixedIpInput"; import { MAC_ADDRESS_REGEX } from "@/app/base/validation"; +import { reservedIpActions } from "@/app/store/reservedip"; +import reservedIpSelectors from "@/app/store/reservedip/selectors"; import type { RootState } from "@/app/store/root/types"; import subnetSelectors from "@/app/store/subnet/selectors"; import { @@ -29,9 +33,18 @@ const ReserveDHCPLease = ({ subnetId, setSidePanelContent }: Props) => { const subnet = useSelector((state: RootState) => subnetSelectors.getById(state, subnetId) ); - const loading = useSelector(subnetSelectors.loading); + const subnetLoading = useSelector(subnetSelectors.loading); + const reservedIpLoading = useSelector(reservedIpSelectors.loading); + const errors = useSelector(reservedIpSelectors.errors); + const saving = useSelector(reservedIpSelectors.saving); + const saved = useSelector(reservedIpSelectors.saved); + + const loading = subnetLoading || reservedIpLoading; + + const dispatch = useDispatch(); + const cleanup = useCallback(() => reservedIpActions.cleanup(), []); - const onCancel = () => setSidePanelContent(null); + const onClose = () => setSidePanelContent(null); if (loading) { return ; @@ -77,22 +90,39 @@ const ReserveDHCPLease = ({ subnetId, setSidePanelContent }: Props) => { subnet?.cidr as string ), }), - mac_address: Yup.string() - .required("MAC address is required") - .matches(MAC_ADDRESS_REGEX, "Invalid MAC address"), + mac_address: Yup.string().matches(MAC_ADDRESS_REGEX, "Invalid MAC address"), comment: Yup.string(), }); + const handleSubmit = (values: FormValues) => { + dispatch(cleanup()); + + dispatch( + reservedIpActions.create({ + comment: values.comment, + ip: `${immutableOctets}.${values.ip_address}`, + mac_address: values.mac_address, + subnet: subnetId, + }) + ); + }; + return ( aria-label="Reserve static DHCP lease" + cleanup={cleanup} + errors={errors} initialValues={{ ip_address: "", mac_address: "", comment: "", }} - onCancel={onCancel} - onSubmit={() => {}} + onCancel={onClose} + onSubmit={handleSubmit} + onSuccess={onClose} + resetOnSave + saved={saved} + saving={saving} submitLabel="Reserve static DHCP lease" validationSchema={ReserveDHCPLeaseSchema} > @@ -103,7 +133,7 @@ const ReserveDHCPLease = ({ subnetId, setSidePanelContent }: Props) => { name="ip_address" required /> - + { const { setSidePanelContent } = useSidePanel(); - const staticDHCPLeases = useStaticDHCPLeases(subnetId); + const staticDHCPLeases = useReservedIps(subnetId); + const loading = useSelector((state: RootState) => + reservedIpSelectors.loading(state) + ); return ( <> @@ -36,7 +42,7 @@ const StaticDHCPLease = ({ subnetId }: StaticDHCPLeaseProps) => { - + ); }; diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.test.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.test.tsx index 1ecd51ee41..243c83c83e 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.test.tsx @@ -1,10 +1,10 @@ import StaticDHCPTable from "./StaticDHCPTable"; -import { staticDHCPLease } from "@/testing/factories/subnet"; +import { reservedIp } from "@/testing/factories/reservedip"; import { renderWithBrowserRouter, screen } from "@/testing/utils"; it("renders a static DHCP table with no data", () => { - renderWithBrowserRouter(); + renderWithBrowserRouter(); expect( screen.getByRole("table", { name: "Static DHCP leases" }) @@ -15,13 +15,15 @@ it("renders a static DHCP table with no data", () => { }); it("renders a static DHCP table when data is provided", () => { - const dhcpLeases = [staticDHCPLease(), staticDHCPLease()]; - renderWithBrowserRouter(); + const reservedIps = [reservedIp(), reservedIp()]; + renderWithBrowserRouter( + + ); expect( screen.getByRole("table", { name: "Static DHCP leases" }) ).toBeInTheDocument(); expect( - screen.getByRole("cell", { name: dhcpLeases[0].ip_address }) + screen.getByRole("cell", { name: reservedIps[0].ip }) ).toBeInTheDocument(); }); diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.tsx index 0417e04c03..329e80c258 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPTable/StaticDHCPTable.tsx @@ -2,7 +2,12 @@ import { DynamicTable, TableCaption } from "@canonical/maas-react-components"; import { Link } from "react-router-dom"; import TableActions from "@/app/base/components/TableActions"; -import type { StaticDHCPLease } from "@/app/store/subnet/types/base"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import type { ReservedIp } from "@/app/store/reservedip/types/base"; +import { getNodeUrl } from "@/app/store/reservedip/utils"; +import { getNodeTypeDisplay } from "@/app/store/utils"; +import { SubnetDetailsSidePanelViews } from "@/app/subnets/views/SubnetDetails/constants"; const headers = [ { content: "IP Address", className: "ip-col", sortKey: "ip_address" }, @@ -14,43 +19,63 @@ const headers = [ sortKey: "interface", }, { content: "Usage", className: "usage-col", sortKey: "usage" }, - { content: "Owner", className: "oner-col", sortKey: "owner" }, { content: "Comment", className: "comment-col", sortKey: "comment" }, { content: "Actions", className: "actions-col" }, ] as const; -const generateRows = (dhcpLeases: StaticDHCPLease[]) => - dhcpLeases.map((dhcpLease) => { +const generateRows = ( + reservedIps: ReservedIp[], + setSidePanelContent: SetSidePanelContent +) => + reservedIps.map((reservedIp) => { return ( - - {dhcpLease.ip_address} - {dhcpLease.mac_address} + + {reservedIp.ip} + {reservedIp.mac_address || "—"} - {dhcpLease?.node ? ( - - {dhcpLease.node.hostname}. - {dhcpLease.node.fqdn.split(".")[1]} + {reservedIp?.node_summary ? ( + + {reservedIp.node_summary.hostname}. + {reservedIp.node_summary.fqdn.split(".")[1]} ) : ( "—" )} - {dhcpLease.interface} - {dhcpLease.usage || "—"} - {dhcpLease.owner} - {dhcpLease.comment || "—"} + {reservedIp.node_summary?.via || "—"} - {}} onEdit={() => {}} /> + {reservedIp.node_summary?.node_type !== undefined + ? getNodeTypeDisplay(reservedIp.node_summary.node_type) + : "—"} + + {reservedIp.comment || "—"} + + + setSidePanelContent({ + view: SubnetDetailsSidePanelViews.DeleteDHCPLease, + extras: { reservedIpId: reservedIp.id }, + }) + } + onEdit={() => {}} + /> ); }); type Props = { - staticDHCPLeases: StaticDHCPLease[]; + reservedIps: ReservedIp[]; + loading: boolean; }; -const StaticDHCPTable = ({ staticDHCPLeases }: Props) => { +const StaticDHCPTable = ({ reservedIps, loading }: Props) => { + const { setSidePanelContent } = useSidePanel(); return ( { ))} - {staticDHCPLeases.length ? ( - {generateRows(staticDHCPLeases)} + {loading ? ( + + ) : reservedIps.length ? ( + + {generateRows(reservedIps, setSidePanelContent)} + ) : ( diff --git a/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx b/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx index 03ba42f303..12b016d606 100644 --- a/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx +++ b/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx @@ -37,14 +37,14 @@ const SubnetActionForms = ({ activeForm, setSidePanelContent, staticRouteId, - macAddress, + reservedIpId, }: SubnetActionProps): JSX.Element => { const FormComponent = activeForm ? FormComponents[activeForm] : () => null; return ( , - { createType?: IPRangeType; ipRangeId?: number; staticRouteId?: number } + { + createType?: IPRangeType; + ipRangeId?: number; + staticRouteId?: number; + reservedIpId?: ReservedIp["id"]; + } >; diff --git a/src/app/subnets/views/SubnetDetails/types.ts b/src/app/subnets/views/SubnetDetails/types.ts index 560f5623df..34052f3455 100644 --- a/src/app/subnets/views/SubnetDetails/types.ts +++ b/src/app/subnets/views/SubnetDetails/types.ts @@ -1,6 +1,7 @@ import type { SubnetActionTypes } from "./constants"; import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import type { ReservedIp } from "@/app/store/reservedip/types"; import type { StaticRoute, StaticRouteMeta, @@ -14,5 +15,5 @@ export interface SubnetActionProps { staticRouteId?: StaticRoute[StaticRouteMeta.PK]; activeForm: SubnetAction; setSidePanelContent: SetSidePanelContent; - macAddress?: string; + reservedIpId?: ReservedIp["id"]; } diff --git a/src/root-reducer.ts b/src/root-reducer.ts index 1b212ba0c8..c0b6715c16 100644 --- a/src/root-reducer.ts +++ b/src/root-reducer.ts @@ -23,6 +23,7 @@ import nodescriptresult from "@/app/store/nodescriptresult"; import notification from "@/app/store/notification"; import packagerepository from "@/app/store/packagerepository"; import pod from "@/app/store/pod"; +import reservedip from "@/app/store/reservedip"; import resourcepool from "@/app/store/resourcepool"; import type { RootState } from "@/app/store/root/types"; import script from "@/app/store/script"; @@ -65,6 +66,7 @@ const createAppReducer = (routerReducer: Reducer) => notification, packagerepository, pod, + reservedip, resourcepool, router: routerReducer, scriptresult, diff --git a/src/testing/factories/index.ts b/src/testing/factories/index.ts index a087bd6355..06e856b4ac 100644 --- a/src/testing/factories/index.ts +++ b/src/testing/factories/index.ts @@ -54,6 +54,7 @@ export { podStatus, podStatuses, powerTypesState, + reservedIpState, resourcePoolState, rootState, routerState, @@ -180,6 +181,7 @@ export { modelRef } from "./model"; export { nodeDevice } from "./nodedevice"; export { notification } from "./notification"; export { packageRepository } from "./packagerepository"; +export { reservedIp, reservedIpNodeSummary } from "./reservedip"; export { resourcePool } from "./resourcepool"; export { partialScriptResult, diff --git a/src/testing/factories/reservedip.ts b/src/testing/factories/reservedip.ts new file mode 100644 index 0000000000..4d9003d231 --- /dev/null +++ b/src/testing/factories/reservedip.ts @@ -0,0 +1,28 @@ +import { define, extend, sequence } from "cooky-cutter"; + +import { timestampedModel } from "./model"; + +import type { ReservedIp } from "@/app/store/reservedip/types"; +import type { ReservedIpNodeSummary } from "@/app/store/reservedip/types/base"; +import type { TimestampedModel } from "@/app/store/types/model"; +import { NodeType } from "@/app/store/types/node"; + +export const reservedIpNodeSummary = define({ + fqdn: "springbok.maas", + hostname: "springbok", + node_type: NodeType.MACHINE, + system_id: "abc123", + via: "eth0", +}); + +export const reservedIp = extend( + timestampedModel, + { + id: sequence, + comment: "Lorem ipsum dolor sit amet", + ip: (i: number) => `192.168.1.${i}`, + mac_address: (i: number) => `00:00:00:00:00:${i}`, + subnet: 1, + node_summary: reservedIpNodeSummary, + } +); diff --git a/src/testing/factories/state.ts b/src/testing/factories/state.ts index 19df2b2a1b..d8410e726a 100644 --- a/src/testing/factories/state.ts +++ b/src/testing/factories/state.ts @@ -76,6 +76,7 @@ import type { NotificationState } from "@/app/store/notification/types"; import type { PackageRepositoryState } from "@/app/store/packagerepository/types"; import { DEFAULT_STATUSES as DEFAULT_POD_STATUSES } from "@/app/store/pod/slice"; import type { PodState, PodStatus, PodStatuses } from "@/app/store/pod/types"; +import type { ReservedIpState } from "@/app/store/reservedip/types"; import type { ResourcePoolState } from "@/app/store/resourcepool/types"; import type { RootState } from "@/app/store/root/types"; import type { ScriptState } from "@/app/store/script/types"; @@ -536,6 +537,11 @@ export const nodeScriptResultState = define({ items: () => ({}), }); +export const reservedIpState = define({ + ...defaultState, + errors: null, +}); + export const resourcePoolState = define({ ...defaultState, }); @@ -680,6 +686,7 @@ export const rootState = define({ nodescriptresult: nodeScriptResultState, packagerepository: packageRepositoryState, pod: podState, + reservedip: reservedIpState, resourcepool: resourcePoolState, router: routerState, scriptresult: scriptResultState, diff --git a/src/testing/factories/subnet.ts b/src/testing/factories/subnet.ts index 022ea2090c..7487ba593e 100644 --- a/src/testing/factories/subnet.ts +++ b/src/testing/factories/subnet.ts @@ -1,8 +1,7 @@ -import { array, define, derive, extend, random, sequence } from "cooky-cutter"; +import { array, define, extend, random } from "cooky-cutter"; import { timestamp } from "./general"; import { model, timestampedModel } from "./model"; -import { simpleNode } from "./nodes"; import { PodType } from "@/app/store/pod/constants"; import { IPAddressType } from "@/app/store/subnet/types"; @@ -19,9 +18,7 @@ import type { SubnetScanFailure, SubnetScanResult, } from "@/app/store/subnet/types"; -import type { StaticDHCPLease } from "@/app/store/subnet/types/base"; import type { Model, TimestampedModel } from "@/app/store/types/model"; -import type { SimpleNode } from "@/app/store/types/node"; import { NodeType } from "@/app/store/types/node"; export const subnetStatisticsRange = define({ @@ -113,14 +110,3 @@ export const subnet = extend(timestampedModel, { export const subnetDetails = extend(subnet, { ip_addresses: () => [], }); - -export const staticDHCPLease = define({ - id: sequence, - comment: "random comment", - ip_address: (i: number) => `192.168.1.${i}`, - mac_address: (i: number) => `00:00:00:00:00:${i}`, - interface: (i: number) => `eth${i}`, - usage: "Device", - node: derive(simpleNode), - owner: "test-owner", -});