From 1bad2df901045b2b21638a5fe24fc6ab189f6cd7 Mon Sep 17 00:00:00 2001 From: Nick De Villiers Date: Tue, 7 May 2024 08:48:20 +0100 Subject: [PATCH] feat(subnets): Add form to reserve DHCP lease MAASENG-2975 (#5420) Co-authored-by: Peter Makowski --- .../PrefixedInput/PrefixedInput.test.tsx | 42 +++++++ .../PrefixedInput/PrefixedInput.tsx | 55 ++++++++ .../base/components/PrefixedInput/_index.scss | 16 +++ .../base/components/PrefixedInput/index.ts | 1 + .../PrefixedIpInput/PrefixedIpInput.test.tsx | 69 +++++++++++ .../PrefixedIpInput/PrefixedIpInput.tsx | 66 ++++++++++ .../base/components/PrefixedIpInput/index.ts | 1 + .../ReserveDHCPLease.test.tsx | 79 ++++++++++++ .../ReserveDHCPLease/ReserveDHCPLease.tsx | 117 ++++++++++++++++++ .../StaticDHCPLease/ReserveDHCPLease/index.ts | 1 + .../StaticDHCPLease/StaticDHCPLease.tsx | 2 +- .../SubnetActionForms/SubnetActionForms.tsx | 5 +- .../subnets/views/SubnetDetails/constants.ts | 17 ++- src/app/utils/subnetIpRange.test.ts | 25 +++- src/app/utils/subnetIpRange.ts | 28 +++++ src/scss/index.scss | 2 + 16 files changed, 512 insertions(+), 14 deletions(-) create mode 100644 src/app/base/components/PrefixedInput/PrefixedInput.test.tsx create mode 100644 src/app/base/components/PrefixedInput/PrefixedInput.tsx create mode 100644 src/app/base/components/PrefixedInput/_index.scss create mode 100644 src/app/base/components/PrefixedInput/index.ts create mode 100644 src/app/base/components/PrefixedIpInput/PrefixedIpInput.test.tsx create mode 100644 src/app/base/components/PrefixedIpInput/PrefixedIpInput.tsx create mode 100644 src/app/base/components/PrefixedIpInput/index.ts create mode 100644 src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx create mode 100644 src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx create mode 100644 src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/index.ts diff --git a/src/app/base/components/PrefixedInput/PrefixedInput.test.tsx b/src/app/base/components/PrefixedInput/PrefixedInput.test.tsx new file mode 100644 index 0000000000..b6984ac984 --- /dev/null +++ b/src/app/base/components/PrefixedInput/PrefixedInput.test.tsx @@ -0,0 +1,42 @@ +/* eslint-disable testing-library/no-node-access */ +import { render, screen } from "@testing-library/react"; + +import PrefixedInput from "./PrefixedInput"; + +const { getComputedStyle } = window; + +beforeAll(() => { + // getComputedStyle is not implemeneted in jsdom, so we need to do this. + window.getComputedStyle = (elt) => getComputedStyle(elt); +}); + +afterAll(() => { + // Reset to original implementation + window.getComputedStyle = getComputedStyle; +}); + +it("renders without crashing", async () => { + render( + + ); + + expect( + screen.getByRole("textbox", { name: "Limited input" }) + ).toBeInTheDocument(); +}); + +it("sets the --immutable css variable to the provided immutable text", async () => { + const { rerender } = render( + + ); + + rerender( + + ); + + // Direct node access is needed here to check the CSS variable + expect( + screen.getByRole("textbox", { name: "Limited input" }).parentElement + ?.parentElement + ).toHaveStyle(`--immutable: "Some text";`); +}); diff --git a/src/app/base/components/PrefixedInput/PrefixedInput.tsx b/src/app/base/components/PrefixedInput/PrefixedInput.tsx new file mode 100644 index 0000000000..4454a43097 --- /dev/null +++ b/src/app/base/components/PrefixedInput/PrefixedInput.tsx @@ -0,0 +1,55 @@ +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; + +import type { InputProps } from "@canonical/react-components"; +import { Input } from "@canonical/react-components"; +import classNames from "classnames"; + +export type PrefixedInputProps = Omit & { + immutableText: string; +}; + +// TODO: Upstream to maas-react-components https://warthogs.atlassian.net/browse/MAASENG-3113 +const PrefixedInput = ({ immutableText, ...props }: PrefixedInputProps) => { + const prefixedInputRef: RefObject = useRef(null); + + useEffect(() => { + const inputWrapper = prefixedInputRef.current?.firstElementChild; + if (inputWrapper) { + if (props.label) { + // CSS variable "--immutable" is the content of the :before element, which shows the immutable octets + // "--top" is the `top` property of the :before element, which is adjusted if there is a label to prevent overlap + inputWrapper.setAttribute( + "style", + `--top: 2.5rem; --immutable: "${immutableText}"` + ); + } else { + inputWrapper.setAttribute("style", `--immutable: "${immutableText}"`); + } + + const width = window.getComputedStyle(inputWrapper, ":before").width; + + // Adjust the left padding of the input to be the same width as the immutable octets. + // This displays the user input and the unchangeable text together as one IP address. + inputWrapper + .querySelector("input") + ?.setAttribute("style", `padding-left: ${width}`); + } + }, [prefixedInputRef, immutableText, props.label]); + + return ( +
+ +
+ ); +}; + +export default PrefixedInput; diff --git a/src/app/base/components/PrefixedInput/_index.scss b/src/app/base/components/PrefixedInput/_index.scss new file mode 100644 index 0000000000..aa57e83f3a --- /dev/null +++ b/src/app/base/components/PrefixedInput/_index.scss @@ -0,0 +1,16 @@ +@mixin PrefixedInput { + .prefixed-input { + position: relative; + + &__wrapper::before { + position: absolute; + pointer-events: none; + padding-left: $spv--small; + padding-bottom: calc(0.4rem - 1px); + padding-top: calc(0.4rem - 1px); + // TODO: Investigate replacement for using these variables https://warthogs.atlassian.net/browse/MAASENG-3116 + content: var(--immutable, ""); + top: var(--top, "inherit"); + } + } +} diff --git a/src/app/base/components/PrefixedInput/index.ts b/src/app/base/components/PrefixedInput/index.ts new file mode 100644 index 0000000000..14b37598f4 --- /dev/null +++ b/src/app/base/components/PrefixedInput/index.ts @@ -0,0 +1 @@ +export { default } from "./PrefixedInput"; diff --git a/src/app/base/components/PrefixedIpInput/PrefixedIpInput.test.tsx b/src/app/base/components/PrefixedIpInput/PrefixedIpInput.test.tsx new file mode 100644 index 0000000000..cc6c9e8024 --- /dev/null +++ b/src/app/base/components/PrefixedIpInput/PrefixedIpInput.test.tsx @@ -0,0 +1,69 @@ +/* eslint-disable testing-library/no-node-access */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Formik } from "formik"; + +import FormikField from "../FormikField"; +import FormikForm from "../FormikForm"; + +import PrefixedIpInput from "./PrefixedIpInput"; + +import { renderWithBrowserRouter } from "@/testing/utils"; + +const { getComputedStyle } = window; + +beforeAll(() => { + // getComputedStyle is not implemeneted in jsdom, so we need to do this. + window.getComputedStyle = (elt) => getComputedStyle(elt); +}); + +afterAll(() => { + // Reset to original implementation + window.getComputedStyle = getComputedStyle; +}); + +it("displays the correct range help text for a subnet", () => { + render( + + + + ); + expect(screen.getByText("10.0.0.[1-254]")).toBeInTheDocument(); +}); + +it("sets the --immutable css variable to the immutable octets of the subnet", () => { + render( + + + + ); + + // Direct node access is needed here to check the CSS variable + expect( + screen.getByRole("textbox", { name: "IP address" }).parentElement + ?.parentElement + ).toHaveStyle(`--immutable: "10.0.0."`); +}); + +it("displays the correct placeholder for a subnet", () => { + render( + + + + ); + + expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "[1-254]"); +}); + +it("trims the immutable octets from a pasted IP address", async () => { + renderWithBrowserRouter( + + + + ); + + await userEvent.click(screen.getByRole("textbox")); + await userEvent.paste("10.0.0.1"); + + expect(screen.getByRole("textbox")).toHaveValue("1"); +}); diff --git a/src/app/base/components/PrefixedIpInput/PrefixedIpInput.tsx b/src/app/base/components/PrefixedIpInput/PrefixedIpInput.tsx new file mode 100644 index 0000000000..89dec51935 --- /dev/null +++ b/src/app/base/components/PrefixedIpInput/PrefixedIpInput.tsx @@ -0,0 +1,66 @@ +import { useFormikContext } from "formik"; + +import PrefixedInput from "../PrefixedInput"; +import type { PrefixedInputProps } from "../PrefixedInput/PrefixedInput"; + +import type { Subnet } from "@/app/store/subnet/types"; +import { + getImmutableAndEditableOctets, + getIpRangeFromCidr, +} from "@/app/utils/subnetIpRange"; + +type Props = Omit< + PrefixedInputProps, + "maxLength" | "placeholder" | "name" | "immutableText" +> & { + cidr: Subnet["cidr"]; + name: string; +}; + +const PrefixedIpInput = ({ cidr, name, ...props }: Props) => { + const [startIp, endIp] = getIpRangeFromCidr(cidr); + const [immutable, editable] = getImmutableAndEditableOctets(startIp, endIp); + + const formikProps = useFormikContext(); + + const getMaxLength = () => { + const immutableOctetsLength = immutable.split(".").length; + + if (immutableOctetsLength === 3) { + return 3; // 3 digits, no dots + } else if (immutableOctetsLength === 2) { + return 7; // 6 digits, 1 dot + } else if (immutableOctetsLength === 1) { + return 11; // 9 digits, 2 dots + } else { + return 15; // 12 digits, 3 dots + } + }; + + return ( + + The available range in this subnet is{" "} + + {immutable}.{editable} + + + } + immutableText={`${immutable}.`} + maxLength={getMaxLength()} + name={name} + onPaste={(e) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData("text"); + const octets = pastedText.split("."); + const trimmed = octets.slice(0 - editable.split(".").length); + formikProps.setFieldValue(name, trimmed.join(".")); + }} + placeholder={editable} + {...props} + /> + ); +}; + +export default PrefixedIpInput; diff --git a/src/app/base/components/PrefixedIpInput/index.ts b/src/app/base/components/PrefixedIpInput/index.ts new file mode 100644 index 0000000000..dac05e2677 --- /dev/null +++ b/src/app/base/components/PrefixedIpInput/index.ts @@ -0,0 +1 @@ +export { default } from "./PrefixedIpInput"; diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx new file mode 100644 index 0000000000..b900fee4e5 --- /dev/null +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx @@ -0,0 +1,79 @@ +import ReserveDHCPLease from "./ReserveDHCPLease"; + +import type { RootState } from "@/app/store/root/types"; +import * as factory from "@/testing/factories"; +import { + getTestState, + renderWithBrowserRouter, + userEvent, + screen, +} from "@/testing/utils"; + +const { getComputedStyle } = window; +let state: RootState; + +beforeAll(() => { + // getComputedStyle is not implemeneted in jsdom, so we need to do this. + window.getComputedStyle = (elt) => getComputedStyle(elt); +}); + +beforeEach(() => { + state = getTestState(); + state.subnet = factory.subnetState({ + loading: false, + loaded: true, + items: [factory.subnet({ id: 1, cidr: "10.0.0.0/24" })], + }); +}); + +afterAll(() => { + // Reset to original implementation + window.getComputedStyle = getComputedStyle; +}); + +it("displays an error if an invalid IP address is entered", async () => { + renderWithBrowserRouter( + , + { state } + ); + + await userEvent.type( + screen.getByRole("textbox", { name: "IP address" }), + "420" + ); + await userEvent.tab(); + + expect( + screen.getByText("This is not a valid IP address") + ).toBeInTheDocument(); +}); + +it("displays an error if an out-of-range IP address is entered", async () => { + state.subnet.items = [factory.subnet({ id: 1, cidr: "10.0.0.0/25" })]; + renderWithBrowserRouter( + , + { state } + ); + + await userEvent.type( + screen.getByRole("textbox", { name: "IP address" }), + "129" + ); + await userEvent.tab(); + + expect( + screen.getByText("The IP address is outside of the subnet's range.") + ).toBeInTheDocument(); +}); + +it("closes the side panel when the cancel button is clicked", async () => { + const setSidePanelContent = vi.fn(); + renderWithBrowserRouter( + , + { state } + ); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(setSidePanelContent).toHaveBeenCalledWith(null); +}); diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx new file mode 100644 index 0000000000..4841c47825 --- /dev/null +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.tsx @@ -0,0 +1,117 @@ +import { Spinner } from "@canonical/react-components"; +import { useSelector } from "react-redux"; +import * as Yup from "yup"; + +import type { SubnetActionProps } from "../../types"; + +import FormikField from "@/app/base/components/FormikField"; +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 type { RootState } from "@/app/store/root/types"; +import subnetSelectors from "@/app/store/subnet/selectors"; +import { + getImmutableAndEditableOctets, + getIpRangeFromCidr, + isIpInSubnet, +} from "@/app/utils/subnetIpRange"; + +type Props = Pick; + +type FormValues = { + ip_address: string; + mac_address: string; + comment: string; +}; + +const ReserveDHCPLease = ({ subnetId, setSidePanelContent }: Props) => { + const subnet = useSelector((state: RootState) => + subnetSelectors.getById(state, subnetId) + ); + const loading = useSelector(subnetSelectors.loading); + + const onCancel = () => setSidePanelContent(null); + + if (loading) { + return ; + } + + if (!subnet) { + return null; + } + + const [startIp, endIp] = getIpRangeFromCidr(subnet.cidr); + const [immutableOctets, _] = getImmutableAndEditableOctets(startIp, endIp); + + const ReserveDHCPLeaseSchema = Yup.object().shape({ + ip_address: Yup.string() + .required("IP address is required") + .test({ + name: "ip-is-valid", + message: "This is not a valid IP address", + test: (ip_address) => { + let valid = true; + const octets = `${ip_address}`.split("."); + octets.forEach((octet) => { + // IP address is not valid if the octet is not a number + if (isNaN(parseInt(octet))) { + valid = false; + } else { + const octetInt = parseInt(octet); + // Unsigned 8-bit integer cannot be less than 0 or greater than 255 + if (octetInt < 0 || octetInt > 255) { + valid = false; + } + } + }); + return valid; + }, + }) + .test({ + name: "ip-is-in-subnet", + message: "The IP address is outside of the subnet's range.", + test: (ip_address) => + isIpInSubnet( + `${immutableOctets}.${ip_address}`, + subnet?.cidr as string + ), + }), + mac_address: Yup.string() + .required("MAC address is required") + .matches(MAC_ADDRESS_REGEX, "Invalid MAC address"), + comment: Yup.string(), + }); + + return ( + + aria-label="Reserve static DHCP lease" + initialValues={{ + ip_address: "", + mac_address: "", + comment: "", + }} + onCancel={onCancel} + onSubmit={() => {}} + submitLabel="Reserve static DHCP lease" + validationSchema={ReserveDHCPLeaseSchema} + > + + + + + ); +}; + +export default ReserveDHCPLease; diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/index.ts b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/index.ts new file mode 100644 index 0000000000..36d87d0c15 --- /dev/null +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/index.ts @@ -0,0 +1 @@ +export { default } from "./ReserveDHCPLease"; diff --git a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPLease.tsx b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPLease.tsx index 73dcfadc2e..f0d7dd4bcc 100644 --- a/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPLease.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticDHCPLease/StaticDHCPLease.tsx @@ -27,7 +27,7 @@ const StaticDHCPLease = ({ subnetId }: StaticDHCPLeaseProps) => { onClick={() => setSidePanelContent({ view: SubnetDetailsSidePanelViews[ - SubnetActionTypes.ReserveStaticDHCPLease + SubnetActionTypes.ReserveDHCPLease ], }) } diff --git a/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx b/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx index ef583b0a4a..03ba42f303 100644 --- a/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx +++ b/src/app/subnets/views/SubnetDetails/SubnetActionForms/SubnetActionForms.tsx @@ -5,6 +5,7 @@ import MapSubnet from "./components/MapSubnet"; import ReservedRangeDeleteForm from "@/app/subnets/components/ReservedRangeDeleteForm"; import ReservedRangeForm from "@/app/subnets/components/ReservedRangeForm"; import DeleteDHCPLease from "@/app/subnets/views/SubnetDetails/StaticDHCPLease/DeleteDHCPLease"; +import ReserveDHCPLease from "@/app/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease"; import AddStaticRouteForm from "@/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm"; import DeleteStaticRouteForm from "@/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform"; import EditStaticRouteForm from "@/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm"; @@ -26,8 +27,8 @@ const FormComponents: Record< [SubnetActionTypes.DeleteStaticRoute]: DeleteStaticRouteForm, [SubnetActionTypes.ReserveRange]: ReservedRangeForm, [SubnetActionTypes.DeleteReservedRange]: ReservedRangeDeleteForm, - [SubnetActionTypes.ReserveStaticDHCPLease]: () => null, - [SubnetActionTypes.EditStaticDHCPLease]: () => null, + [SubnetActionTypes.ReserveDHCPLease]: ReserveDHCPLease, + [SubnetActionTypes.EditDHCPLease]: () => null, [SubnetActionTypes.DeleteDHCPLease]: DeleteDHCPLease, }; diff --git a/src/app/subnets/views/SubnetDetails/constants.ts b/src/app/subnets/views/SubnetDetails/constants.ts index 291e16820f..2743105c22 100644 --- a/src/app/subnets/views/SubnetDetails/constants.ts +++ b/src/app/subnets/views/SubnetDetails/constants.ts @@ -12,8 +12,8 @@ export const SubnetActionTypes = { DeleteStaticRoute: "DeleteStaticRoute", ReserveRange: "ReserveRange", DeleteReservedRange: "DeleteReservedRange", - ReserveStaticDHCPLease: "ReserveStaticDHCPLease", - EditStaticDHCPLease: "EditStaticDHCPLease", + ReserveDHCPLease: "ReserveDHCPLease", + EditDHCPLease: "EditDHCPLease", DeleteDHCPLease: "DeleteDHCPLease", } as const; export type SubnetActionType = ValueOf; @@ -27,8 +27,8 @@ export const subnetActionLabels = { [SubnetActionTypes.DeleteStaticRoute]: "Delete static route", [SubnetActionTypes.ReserveRange]: "Reserve range", [SubnetActionTypes.DeleteReservedRange]: "Delete Reserved Range", - [SubnetActionTypes.ReserveStaticDHCPLease]: "Reserve static DHCP lease", - [SubnetActionTypes.EditStaticDHCPLease]: "Edit static DHCP lease", + [SubnetActionTypes.ReserveDHCPLease]: "Reserve static DHCP lease", + [SubnetActionTypes.EditDHCPLease]: "Edit static DHCP lease", [SubnetActionTypes.DeleteDHCPLease]: "Delete static DHCP lease", } as const; @@ -50,14 +50,11 @@ export const SubnetDetailsSidePanelViews = { "", SubnetActionTypes.DeleteReservedRange, ], - [SubnetActionTypes.ReserveStaticDHCPLease]: [ + [SubnetActionTypes.ReserveDHCPLease]: [ "", - SubnetActionTypes.ReserveStaticDHCPLease, - ], - [SubnetActionTypes.EditStaticDHCPLease]: [ - "", - SubnetActionTypes.EditStaticDHCPLease, + SubnetActionTypes.ReserveDHCPLease, ], + [SubnetActionTypes.EditDHCPLease]: ["", SubnetActionTypes.EditDHCPLease], [SubnetActionTypes.DeleteDHCPLease]: ["", SubnetActionTypes.DeleteDHCPLease], } as const; diff --git a/src/app/utils/subnetIpRange.test.ts b/src/app/utils/subnetIpRange.test.ts index 9fe7f629e9..e3985f24b4 100644 --- a/src/app/utils/subnetIpRange.test.ts +++ b/src/app/utils/subnetIpRange.test.ts @@ -1,4 +1,8 @@ -import { getIpRangeFromCidr, isIpInSubnet } from "./subnetIpRange"; +import { + getImmutableAndEditableOctets, + getIpRangeFromCidr, + isIpInSubnet, +} from "./subnetIpRange"; describe("getIpRangeFromCidr", () => { it("returns the start and end IP of a subnet", () => { @@ -53,3 +57,22 @@ describe("isIpInSubnet", () => { expect(isIpInSubnet("10.0.0.255", "10.0.0.0/24")).toBe(false); }); }); + +describe("getImmutableAndEditableOctets", () => { + it("returns the immutable and editable octets for a given subnet range", () => { + expect(getImmutableAndEditableOctets("10.0.0.1", "10.0.0.254")).toEqual([ + "10.0.0", + "[1-254]", + ]); + expect(getImmutableAndEditableOctets("10.0.0.1", "10.0.255.254")).toEqual([ + "10.0", + "[0-255].[1-254]", + ]); + expect(getImmutableAndEditableOctets("10.0.0.1", "10.255.255.254")).toEqual( + ["10", "[0-255].[0-255].[1-254]"] + ); + expect(getImmutableAndEditableOctets("10.0.0.1", "20.255.255.254")).toEqual( + ["", "[10-20].[0-255].[0-255].[1-254]"] + ); + }); +}); diff --git a/src/app/utils/subnetIpRange.ts b/src/app/utils/subnetIpRange.ts index 276508e5d3..d61cf1344e 100644 --- a/src/app/utils/subnetIpRange.ts +++ b/src/app/utils/subnetIpRange.ts @@ -70,3 +70,31 @@ export const isIpInSubnet = (ip: string, cidr: Subnet["cidr"]) => { return ipUint32 >= startIPUint32 && ipUint32 <= endIPUint32; }; + +/** + * Separates the immutable and editable octets of an IPv4 subnet range. + * + * @param startIp The start IP of the subnet + * @param endIp The end IP of the subnet + * @returns The immutable and editable octects as two strings in a list + */ +export const getImmutableAndEditableOctets = ( + startIp: string, + endIp: string +) => { + const startIpOctetList = startIp.split("."); + const endIpOctetList = endIp.split("."); + + let immutable: string[] = []; + let editable: string[] = []; + + startIpOctetList.forEach((octet, index) => { + if (octet === endIpOctetList[index]) { + immutable.push(octet); + } else { + editable.push(`[${octet}-${endIpOctetList[index]}]`); + } + }); + + return [immutable.join("."), editable.join(".")]; +}; diff --git a/src/scss/index.scss b/src/scss/index.scss index 7935fbd8d0..a510a30a2c 100644 --- a/src/scss/index.scss +++ b/src/scss/index.scss @@ -106,6 +106,7 @@ @import "@/app/base/components/NotificationGroup"; @import "@/app/base/components/Placeholder"; @import "@/app/base/components/Popover"; +@import "@/app/base/components/PrefixedInput"; @import "@/app/base/components/SecondaryNavigation"; @import "@/app/base/components/SectionHeader"; @import "@/app/base/components/SelectButton"; @@ -140,6 +141,7 @@ @include OverviewCard; @include Placeholder; @include Popover; +@include PrefixedInput; @include SecondaryNavigation; @include SectionHeader; @include SelectButton;