From c16c2abf41cc9260a5f2f9a3d5a6205c8ba9649f Mon Sep 17 00:00:00 2001 From: Omar Elkashef Date: Fri, 16 Jan 2026 16:21:30 +0200 Subject: [PATCH] feat(prefixedIpInput) add prefixedIpInput WD-32589 Signed-off-by: Omar Elkashef --- .../PrefixedInput/PrefixedInput.scss | 22 ++ .../PrefixedInput/PrefixedInput.stories.tsx | 62 ++++ .../PrefixedInput/PrefixedInput.test.tsx | 120 ++++++++ .../PrefixedInput/PrefixedInput.tsx | 78 +++++ src/components/PrefixedInput/index.ts | 1 + .../PrefixedIpInput.stories.tsx | 129 ++++++++ .../PrefixedIpInput/PrefixedIpInput.test.tsx | 284 ++++++++++++++++++ .../PrefixedIpInput/PrefixedIpInput.tsx | 120 ++++++++ src/components/PrefixedIpInput/index.ts | 10 + src/components/PrefixedIpInput/utils.test.ts | 130 ++++++++ src/components/PrefixedIpInput/utils.ts | 134 +++++++++ src/index.ts | 13 + 12 files changed, 1103 insertions(+) create mode 100644 src/components/PrefixedInput/PrefixedInput.scss create mode 100644 src/components/PrefixedInput/PrefixedInput.stories.tsx create mode 100644 src/components/PrefixedInput/PrefixedInput.test.tsx create mode 100644 src/components/PrefixedInput/PrefixedInput.tsx create mode 100644 src/components/PrefixedInput/index.ts create mode 100644 src/components/PrefixedIpInput/PrefixedIpInput.stories.tsx create mode 100644 src/components/PrefixedIpInput/PrefixedIpInput.test.tsx create mode 100644 src/components/PrefixedIpInput/PrefixedIpInput.tsx create mode 100644 src/components/PrefixedIpInput/index.ts create mode 100644 src/components/PrefixedIpInput/utils.test.ts create mode 100644 src/components/PrefixedIpInput/utils.ts diff --git a/src/components/PrefixedInput/PrefixedInput.scss b/src/components/PrefixedInput/PrefixedInput.scss new file mode 100644 index 000000000..c0493c78f --- /dev/null +++ b/src/components/PrefixedInput/PrefixedInput.scss @@ -0,0 +1,22 @@ +@import "vanilla-framework"; + +.prefixed-input { + position: relative; + + .prefixed-input__input { + padding-top: 0.25rem; + } + + .prefixed-input__text { + padding-left: $spv--small; + padding-top: 0.3rem; + pointer-events: none; + position: absolute; + } + + &--with-label { + .prefixed-input__text { + top: 2.5rem; + } + } +} diff --git a/src/components/PrefixedInput/PrefixedInput.stories.tsx b/src/components/PrefixedInput/PrefixedInput.stories.tsx new file mode 100644 index 000000000..cabf4c18f --- /dev/null +++ b/src/components/PrefixedInput/PrefixedInput.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import PrefixedInput from "./PrefixedInput"; + +const meta: Meta = { + component: PrefixedInput, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + immutableText: "https://", + placeholder: "example.com", + }, +}; + +export const WithLabel: Story = { + args: { + immutableText: "https://", + label: "Website URL", + placeholder: "example.com", + }, +}; + +export const Disabled: Story = { + args: { + immutableText: "@", + label: "Username", + placeholder: "username", + disabled: true, + }, +}; + +export const WithError: Story = { + args: { + immutableText: "https://", + label: "Website URL", + placeholder: "example.com", + error: "Invalid URL format", + }, +}; + +export const WithHelpText: Story = { + args: { + immutableText: "User ID:", + label: "User Identifier", + placeholder: " Enter user ID", + help: "This will be used to identify your account", + }, +}; + +export const Required: Story = { + args: { + immutableText: "https://", + label: "Website URL", + placeholder: "example.com", + required: true, + }, +}; diff --git a/src/components/PrefixedInput/PrefixedInput.test.tsx b/src/components/PrefixedInput/PrefixedInput.test.tsx new file mode 100644 index 000000000..0692f307a --- /dev/null +++ b/src/components/PrefixedInput/PrefixedInput.test.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { render } from "@testing-library/react"; + +import PrefixedInput, { PrefixedInputProps } from "./PrefixedInput"; + +// Mock the Input component +jest.mock("components/Input", () => { + return function MockInput(props: PrefixedInputProps) { + return ; + }; +}); + +jest.mock("classnames", () => { + return jest.fn((...args) => { + return args + .filter(Boolean) + .map((arg) => { + if (typeof arg === "string") { + return arg; + } + if (typeof arg === "object" && arg !== null) { + return Object.keys(arg) + .filter((key) => arg[key]) + .join(" "); + } + return ""; + }) + .filter(Boolean) + .join(" "); + }); +}); + +describe("PrefixedInput", () => { + beforeEach(() => { + // Mock getBoundingClientRect + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 50, + height: 20, + top: 0, + left: 0, + bottom: 20, + right: 50, + x: 0, + y: 0, + toJSON: () => {}, + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders the immutable text", () => { + const { container } = render(); + expect(container).toContainHTML("https://"); + }); + + it("passes extra classes to the input element", () => { + const { container } = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + expect(input).toHaveClass("prefixed-input__input extra-class"); + }); + + it("renders with label class when label is provided", () => { + const { container } = render( + , + ); + const element = container.querySelector(".prefixed-input"); + expect(element).toHaveClass("prefixed-input prefixed-input--with-label"); + }); + + it("renders without label class when label is not provided", () => { + const { container } = render(); + expect(container.querySelector(".prefixed-input--with-label")).toBeNull(); + }); + + it("updates padding on window resize", () => { + const { container } = render(); + const input = container.querySelector("input"); + + expect(input?.style.paddingLeft).toBe("50px"); + + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 100, + height: 20, + top: 0, + left: 0, + bottom: 20, + right: 100, + x: 0, + y: 0, + toJSON: () => {}, + })); + + window.dispatchEvent(new Event("resize")); + + expect(input?.style.paddingLeft).toBe("100px"); + }); + + it("passes additional props to the Input component", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveAttribute("placeholder", "Enter text"); + expect(input).toHaveAttribute("disabled"); + }); + + it("sets input type to text", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input).toHaveAttribute("type", "text"); + }); +}); diff --git a/src/components/PrefixedInput/PrefixedInput.tsx b/src/components/PrefixedInput/PrefixedInput.tsx new file mode 100644 index 000000000..edbf84b2d --- /dev/null +++ b/src/components/PrefixedInput/PrefixedInput.tsx @@ -0,0 +1,78 @@ +import React, { type ReactElement } from "react"; +import { useLayoutEffect, useRef, useCallback } from "react"; + +import Input, { type InputProps } from "components/Input"; +import classNames from "classnames"; +import "./PrefixedInput.scss"; +import { PropsWithSpread } from "types"; + +// export type PrefixedInputProps = Omit & { +// /** +// * The immutable text that appears at the beginning of the input field. +// * This text is not editable by the user and visually appears inside the input. +// */ +// immutableText: string; +// }; + +export type PrefixedInputProps = PropsWithSpread< + { + /** + * The immutable text that appears at the beginning of the input field. + * This text is not editable by the user and visually appears inside the input. + */ + immutableText: string; + }, + Omit +>; + +const PrefixedInput = ({ + immutableText, + ...props +}: PrefixedInputProps): ReactElement => { + const prefixTextRef = useRef(null); + const inputWrapperRef = useRef(null); + + const updatePadding = useCallback(() => { + const prefixElement = prefixTextRef.current; + const inputElement = inputWrapperRef.current?.querySelector("input"); + + if (prefixElement && inputElement) { + // Adjust the left padding of the input to be the same width as the immutable text. + // This displays the user input and the unchangeable text together as one combined string. + const prefixWidth = prefixElement.getBoundingClientRect().width; + inputElement.style.paddingLeft = `${prefixWidth}px`; + } + }, []); + + useLayoutEffect(() => { + updatePadding(); + + // Listen for window resize events (includes zoom changes) + window.addEventListener("resize", updatePadding); + }, [immutableText, props.label, updatePadding]); + + return ( +
+
+ {immutableText} +
+
+ +
+
+ ); +}; + +export default PrefixedInput; diff --git a/src/components/PrefixedInput/index.ts b/src/components/PrefixedInput/index.ts new file mode 100644 index 000000000..3ef2ef31d --- /dev/null +++ b/src/components/PrefixedInput/index.ts @@ -0,0 +1 @@ +export { default, type PrefixedInputProps } from "./PrefixedInput"; diff --git a/src/components/PrefixedIpInput/PrefixedIpInput.stories.tsx b/src/components/PrefixedIpInput/PrefixedIpInput.stories.tsx new file mode 100644 index 000000000..0bf1cce64 --- /dev/null +++ b/src/components/PrefixedIpInput/PrefixedIpInput.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React, { useState } from "react"; +import PrefixedIpInput from "./PrefixedIpInput"; + +const PrefixedIpInputWrapper = ( + args: React.ComponentProps, +) => { + const [ip, setIp] = useState(args.ip); + return ( + { + setIp(newIp); + args.onIpChange?.(newIp); + }} + /> + ); +}; + +const meta: Meta = { + component: PrefixedIpInput, + tags: ["autodocs"], + argTypes: { + ip: { control: "text" }, + cidr: { control: "text" }, + label: { control: "text" }, + name: { control: "text" }, + error: { control: "text" }, + help: { control: "text" }, + disabled: { control: "boolean" }, + required: { control: "boolean" }, + }, + render: (args) => , +}; + +export default meta; + +type Story = StoryObj; + +export const IPv4Default: Story = { + name: "IPv4 default", + args: { + cidr: "192.168.1.0/24", + ip: "", + name: "ip-address", + label: "IP Address", + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const IPv4WithValue: Story = { + name: "IPv4 with value", + args: { + cidr: "192.168.1.0/24", + ip: "192.168.1.100", + name: "ip-address", + label: "IP Address", + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const IPv4WithError: Story = { + name: "IPv4 with error", + args: { + cidr: "192.168.1.0/24", + ip: "192.168.1.256", + name: "ip-address", + label: "IP Address", + error: "Invalid IP address", + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const IPv4Disabled: Story = { + name: "IPv4 disabled", + args: { + cidr: "192.168.1.0/24", + ip: "192.168.1.50", + name: "ip-address", + label: "IP Address", + disabled: true, + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const IPv6Default: Story = { + name: "IPv6 default", + args: { + cidr: "2001:db8::/32", + ip: "", + name: "ipv6-address", + label: "IPv6 Address", + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const IPv6WithValue: Story = { + name: "IPv6 with value", + args: { + cidr: "2001:db8::/32", + ip: "2001:db8::1", + name: "ipv6-address", + label: "IPv6 Address", + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const WithCustomHelp: Story = { + args: { + cidr: "10.0.0.0/16", + ip: "", + name: "ip-address", + label: "IP Address", + help: "Enter a custom IP address for this device", + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; + +export const Required: Story = { + args: { + cidr: "192.168.0.0/24", + ip: "", + name: "ip-address", + label: "IP Address", + required: true, + onIpChange: (ip) => console.log("IP changed:", ip), + }, +}; diff --git a/src/components/PrefixedIpInput/PrefixedIpInput.test.tsx b/src/components/PrefixedIpInput/PrefixedIpInput.test.tsx new file mode 100644 index 000000000..3bd21af4e --- /dev/null +++ b/src/components/PrefixedIpInput/PrefixedIpInput.test.tsx @@ -0,0 +1,284 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import PrefixedIpInput, { PrefixedIpInputProps } from "./PrefixedIpInput"; + +jest.mock("classnames", () => + jest.fn((...args) => args.filter(Boolean).join(" ")), +); + +// Mock the wrapper component +const PrefixedIpInputWrapper = (props: PrefixedIpInputProps) => { + const [ip, setIp] = React.useState(props.ip); + + const handleIpChange = (newIp: string) => { + setIp(newIp); + props.onIpChange?.(newIp); + }; + + return ; +}; + +describe("PrefixedIpInput", () => { + beforeEach(() => { + // Mock getBoundingClientRect + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 50, + height: 20, + top: 0, + left: 0, + bottom: 20, + right: 50, + x: 0, + y: 0, + toJSON: () => {}, + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders the immutable IPv4 prefix", () => { + const { container } = render( + , + ); + expect(container).toContainHTML("192.168.1."); + }); + + it("renders the immutable IPv6 prefix", () => { + const { container } = render( + , + ); + expect(container).toContainHTML("2001:db8:"); + }); + + it("displays the editable portion of IPv4 address", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveValue("100"); + }); + + it("displays the editable portion of IPv6 address", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveValue(":1"); + }); + + it("renders default help text for IPv4", () => { + const { container } = render( + , + ); + expect(container).toContainHTML("The available range in this subnet is"); + }); + + it("renders default help text for IPv6", () => { + const { container } = render( + , + ); + expect(container).toContainHTML("The available IPV6 address range is"); + }); + + it("renders custom help text when provided", () => { + const { container } = render( + , + ); + expect(container).toContainHTML("Custom help text"); + }); + + it("calls onIpChange with full IPv4 address on input change", async () => { + const onIpChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await userEvent.type(input, "200"); + + expect(onIpChange).toHaveBeenLastCalledWith("192.168.1.200"); + }); + + it("calls onIpChange with full IPv6 address on input change", async () => { + const onIpChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await userEvent.type(input, ":2"); + + expect(onIpChange).toHaveBeenLastCalledWith("2001:db8::2"); + }); + + it("handles paste event for IPv4 address", async () => { + const onIpChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await userEvent.click(input); + await userEvent.paste("192.168.1.150"); + + expect(onIpChange).toHaveBeenLastCalledWith("192.168.1.150"); + }); + + it("handles paste event for IPv6 address", async () => { + const onIpChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await userEvent.click(input); + await userEvent.paste("2001:db8::5"); + + expect(onIpChange).toHaveBeenCalledWith("2001:db8::5"); + }); + + it("sets correct maxLength for IPv4 input", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveAttribute("maxLength", "3"); + }); + + it("displays placeholder when not disabled", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveAttribute("placeholder"); + expect(input?.getAttribute("placeholder")).not.toBe(""); + }); + + it("hides placeholder when disabled", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveAttribute("placeholder", ""); + }); + + it("passes extra classes to the input component", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveClass("extra-class"); + }); + + it("passes name attribute to input", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input).toHaveAttribute("name", "test-ip"); + }); + + it("calls onIpChange with empty string when input is cleared", async () => { + const onIpChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector("input") as HTMLInputElement; + + await userEvent.clear(input); + + expect(onIpChange).toHaveBeenCalledWith(""); + }); +}); diff --git a/src/components/PrefixedIpInput/PrefixedIpInput.tsx b/src/components/PrefixedIpInput/PrefixedIpInput.tsx new file mode 100644 index 000000000..4ef13e57c --- /dev/null +++ b/src/components/PrefixedIpInput/PrefixedIpInput.tsx @@ -0,0 +1,120 @@ +import React, { type ClipboardEvent, type ReactElement } from "react"; +import PrefixedInput, { type PrefixedInputProps } from "../PrefixedInput"; +import { getImmutableAndEditable, isIPv4 } from "./utils"; +import { PropsWithSpread } from "types"; + +export type PrefixedIpInputProps = PropsWithSpread< + { + /** + * The CIDR for the subnet (e.g., "192.168.1.0/24" or "2001:db8::/32"). + * Used to calculate the immutable prefix and available IP range. + */ + cidr: string; + /** + * The full IP address value (if available). + * For IPv4: e.g., "192.168.1.100" + * For IPv6: e.g., "2001:db8::1" + */ + ip: string; + /** + * The name attribute for the input field. + */ + name: string; + /** + * Callback function that is called when the IP address changes. + * Receives the full IP address as a string parameter. + */ + onIpChange: (ip: string) => void; + }, + Omit< + PrefixedInputProps, + "immutableText" | "maxLength" | "placeholder" | "name" + > +>; +const PrefixedIpInput = ({ + cidr, + help, + onIpChange, + ip, + name, + ...props +}: PrefixedIpInputProps): ReactElement => { + const [networkAddress] = cidr.split("/"); + const isIPV4 = isIPv4(networkAddress); + const [immutable, editable] = getImmutableAndEditable(cidr); + const inputValue = isIPV4 + ? ip.split(".").slice(immutable.split(".").length).join(".") + : ip.replace(immutable, ""); + const getIPv4MaxLength = () => { + const immutableOctetsLength = immutable.split(".").length; + const lengths = [15, 11, 7, 3]; // Corresponding to 0-3 immutable octets + return lengths[immutableOctetsLength]; + }; + const maxLength = isIPV4 ? getIPv4MaxLength() : editable.length; + const placeholder = props.disabled ? "" : editable; + + const setIp = (editableValue: string) => { + const fullIp = editableValue + ? isIPV4 + ? `${immutable}.${editableValue}` + : `${immutable}${editableValue}` + : ""; + onIpChange(fullIp); + }; + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData("text"); + if (isIPV4) { + const octets = pastedText.split("."); + const trimmed = octets.slice(0 - editable.split(".").length); + const ip = trimmed.join("."); + setIp(ip); + } else { + const ip = pastedText.replace(immutable, ""); + setIp(ip); + } + }; + return ( + + {" "} + {isIPV4 ? ( + <> + {" "} + The available range in this subnet is{" "} + + {immutable}.{editable}{" "} + + + ) : ( + <> + {" "} + The available IPV6 address range is{" "} + + {immutable} + {editable}{" "} + + + )} + . + + ) + } + immutableText={isIPV4 ? `${immutable}.` : immutable} + maxLength={maxLength} + name={name} + onPaste={handlePaste} + value={inputValue} + onChange={(e) => { + setIp(e.target.value); + }} + placeholder={placeholder} + {...props} + /> + ); +}; +export default PrefixedIpInput; diff --git a/src/components/PrefixedIpInput/index.ts b/src/components/PrefixedIpInput/index.ts new file mode 100644 index 000000000..39b636d0c --- /dev/null +++ b/src/components/PrefixedIpInput/index.ts @@ -0,0 +1,10 @@ +export { default, type PrefixedIpInputProps } from "./PrefixedIpInput"; +export { + isIPv4, + getIpRangeFromCidr, + getFirstValidIp, + convertIpToUint32, + isIpInSubnet, + getImmutableAndEditableOctets, + getImmutableAndEditable, +} from "./utils"; diff --git a/src/components/PrefixedIpInput/utils.test.ts b/src/components/PrefixedIpInput/utils.test.ts new file mode 100644 index 000000000..5755689e9 --- /dev/null +++ b/src/components/PrefixedIpInput/utils.test.ts @@ -0,0 +1,130 @@ +import { + getImmutableAndEditable, + getImmutableAndEditableOctets, + getIpRangeFromCidr, + isIpInSubnet, + isIPv4, +} from "./utils"; + +describe("isIPv4", () => { + it("returns true for valid IPv4 addresses", () => { + expect(isIPv4("192.168.1.1")).toBe(true); + expect(isIPv4("255.255.255.255")).toBe(true); + expect(isIPv4("0.0.0.0")).toBe(true); + }); + it("returns false for invalid IPv4 addresses", () => { + expect(isIPv4("256.256.256.256")).toBe(false); + expect(isIPv4("192.168.1")).toBe(false); + expect(isIPv4("abc.def.ghi.jkl")).toBe(false); + expect(isIPv4("1234.123.123.123")).toBe(false); + }); +}); + +describe("getIpRangeFromCidr", () => { + it("returns the start and end IP of a subnet", () => { + expect(getIpRangeFromCidr("10.0.0.0/26")).toEqual([ + "10.0.0.1", + "10.0.0.62", + ]); + + expect(getIpRangeFromCidr("10.0.0.0/25")).toEqual([ + "10.0.0.1", + "10.0.0.126", + ]); + + expect(getIpRangeFromCidr("10.0.0.0/24")).toEqual([ + "10.0.0.1", + "10.0.0.254", + ]); + + expect(getIpRangeFromCidr("10.0.0.0/23")).toEqual([ + "10.0.0.1", + "10.0.1.254", + ]); + + expect(getIpRangeFromCidr("10.0.0.0/22")).toEqual([ + "10.0.0.1", + "10.0.3.254", + ]); + }); +}); + +describe("isIpInSubnet", () => { + it("returns true if an IP is in a subnet", () => { + expect(isIpInSubnet("10.0.0.1", "10.0.0.0/24")).toBe(true); + expect(isIpInSubnet("10.0.0.254", "10.0.0.0/24")).toBe(true); + expect(isIpInSubnet("192.168.0.1", "192.168.0.0/24")).toBe(true); + expect(isIpInSubnet("192.168.0.254", "192.168.0.0/24")).toBe(true); + expect(isIpInSubnet("192.168.1.1", "192.168.0.0/23")).toBe(true); + }); + + it("returns false if an IP is not in a subnet", () => { + expect(isIpInSubnet("10.0.1.0", "10.0.0.0/24")).toBe(false); + expect(isIpInSubnet("10.1.0.0", "10.0.0.0/24")).toBe(false); + expect(isIpInSubnet("11.0.0.0", "10.0.0.0/24")).toBe(false); + expect(isIpInSubnet("192.168.1.255", "192.168.0.0/23")).toBe(false); + expect(isIpInSubnet("10.0.0.1", "192.168.0.0/24")).toBe(false); + expect(isIpInSubnet("192.168.2.1", "192.168.0.0/24")).toBe(false); + expect(isIpInSubnet("172.16.0.1", "192.168.0.0/24")).toBe(false); + }); + + it("returns false for the network and broadcast addresses", () => { + expect(isIpInSubnet("10.0.0.0", "10.0.0.0/24")).toBe(false); + 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]"], + ); + }); +}); + +describe("getImmutableAndEditable", () => { + it("returns the immutable and editable parts of an IPv4 subnet", () => { + expect(getImmutableAndEditable("10.0.0.0/24")).toEqual([ + "10.0.0", + "[1-254]", + ]); + expect(getImmutableAndEditable("192.168.1.0/24")).toEqual([ + "192.168.1", + "[1-254]", + ]); + expect(getImmutableAndEditable("192.168.0.0/23")).toEqual([ + "192.168", + "[0-1].[1-254]", + ]); + expect(getImmutableAndEditable("172.16.0.0/12")).toEqual([ + "172", + "[16-31].[0-255].[1-254]", + ]); + }); + + it("returns the immutable and editable parts of an IPv6 subnet", () => { + expect(getImmutableAndEditable("2001:0db8:85a3::/64")).toEqual([ + "2001:0db8:85a3:", + "0000:0000:0000:0000:0000", + ]); + expect(getImmutableAndEditable("fd00:1234:5678::/48")).toEqual([ + "fd00:1234:5678:", + "0000:0000:0000:0000:0000", + ]); + expect(getImmutableAndEditable("fe80::/10")).toEqual([ + "fe80:", + "0000:0000:0000:0000:0000:0000:0000", + ]); + }); +}); diff --git a/src/components/PrefixedIpInput/utils.ts b/src/components/PrefixedIpInput/utils.ts new file mode 100644 index 000000000..baaec62cc --- /dev/null +++ b/src/components/PrefixedIpInput/utils.ts @@ -0,0 +1,134 @@ +/** + * Checks if a given IP address is a valid IPv4 address. + * @param ip The IP address to check + * @returns True if the IP is a valid IPv4 address, false otherwise + */ +export const isIPv4 = (ip: string) => { + const ipv4Regex = + /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + + return ipv4Regex.test(ip); +}; + +/** + * Takes a subnet CIDR notation (IPv4) and returns the first and last IP of the subnet. + * The network and host addresses are excluded. + * + * @param cidr The CIDR notation of the subnet + * @returns The first and last valid IP addresses as two strings in a list. + */ +export const getIpRangeFromCidr = (cidr: string): string[] => { + // https://gist.github.com/binarymax/6114792 + + // Get start IP and number of valid addresses + const [startIp, mask] = cidr.split("/"); + const numberOfAddresses = (1 << (32 - parseInt(mask))) - 1; + + // IPv4 can be represented by an unsigned 32-bit integer, so we can use a Uint32Array to store the IP + const buffer = new ArrayBuffer(4); //4 octets + const int32 = new Uint32Array(buffer); + + // Convert starting IP to Uint32 and add the number of addresses to get the end IP. + // Subtract 1 from the number of addresses to exclude the broadcast address. + int32[0] = convertIpToUint32(startIp) + numberOfAddresses - 1; + + // Convert the buffer to a Uint8Array to get the octets, then convert it to an array + const arrayApplyBuffer = Array.from(new Uint8Array(buffer)); + + // Reverse the octets and join them with "." to get the end IP + const endIp = arrayApplyBuffer.reverse().join("."); + + const firstValidIp = getFirstValidIp(startIp); + + return [firstValidIp, endIp]; +}; + +export const getFirstValidIp = (ip: string) => { + const buffer = new ArrayBuffer(4); //4 octets + const int32 = new Uint32Array(buffer); + + // add 1 because the first IP is the network address + int32[0] = convertIpToUint32(ip) + 1; + + const arrayApplyBuffer = Array.from(new Uint8Array(buffer)); + + return arrayApplyBuffer.reverse().join("."); +}; + +export const convertIpToUint32 = (ip: string) => { + const octets = ip.split(".").map((a) => parseInt(a)); + const buffer = new ArrayBuffer(4); + const int32 = new Uint32Array(buffer); + int32[0] = + (octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3]; + return int32[0]; +}; + +/** + * Checks if an IPv4 address is valid for the given subnet. + * + * @param ip The IPv4 address to check, as a string + * @param cidr The subnet's CIDR notation e.g. 192.168.0.0/24 + * @returns True if the IP is in the subnet, false otherwise + */ +export const isIpInSubnet = (ip: string, cidr: string): boolean => { + const [startIP, endIP] = getIpRangeFromCidr(cidr); + + const ipUint32 = convertIpToUint32(ip); + const startIPUint32 = convertIpToUint32(startIP); + const endIPUint32 = convertIpToUint32(endIP); + + 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, +): string[] => { + const startIpOctetList = startIp.split("."); + const endIpOctetList = endIp.split("."); + + const immutable: string[] = []; + const editable: string[] = []; + + startIpOctetList.forEach((octet, index) => { + if (octet === endIpOctetList[index]) { + immutable.push(octet); + } else { + editable.push(`[${octet}-${endIpOctetList[index]}]`); + } + }); + + return [immutable.join("."), editable.join(".")]; +}; + +/** + * Get the immutable and editable parts of an IPv4 or IPv6 subnet. + * + * @param cidr The CIDR notation of the subnet + * @returns The immutable and editable as two strings in a list + */ +export const getImmutableAndEditable = (cidr: string) => { + const isIPV4 = isIPv4(cidr.split("/")[0]); + if (isIPV4) { + const [startIp, endIp] = getIpRangeFromCidr(cidr); + return getImmutableAndEditableOctets(startIp, endIp); + } + + const [networkAddress] = cidr.split("/"); + const immutableIPV6 = networkAddress.substring( + 0, + networkAddress.lastIndexOf(":"), + ); + const ipv6PlaceholderColons = 7 - (immutableIPV6.match(/:/g) || []).length; // 7 is the maximum number of colons in an IPv6 address + const editableIPV6 = `${"0000:".repeat(ipv6PlaceholderColons)}0000`; + + return [immutableIPV6, editableIPV6]; +}; diff --git a/src/index.ts b/src/index.ts index edf0153a9..dbd919ce2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,17 @@ export { default as LoginPageLayout } from "./components/LoginPageLayout"; export { default as Pagination } from "./components/Pagination"; export { default as Panel } from "./components/Panel"; export { default as PasswordToggle } from "./components/PasswordToggle"; +export { default as PrefixedInput } from "./components/PrefixedInput"; +export { + default as PrefixedIpInput, + isIPv4, + getIpRangeFromCidr, + getFirstValidIp, + convertIpToUint32, + isIpInSubnet, + getImmutableAndEditableOctets, + getImmutableAndEditable, +} from "./components/PrefixedIpInput"; export { default as RadioInput } from "./components/RadioInput"; export { default as Row } from "./components/Row"; export { default as ScrollableContainer } from "./components/ScrollableContainer"; @@ -173,6 +184,8 @@ export type { export type { LoginPageLayoutProps } from "./components/LoginPageLayout"; export type { PaginationProps } from "./components/Pagination"; export type { PanelProps } from "./components/Panel"; +export type { PrefixedInputProps } from "./components/PrefixedInput"; +export type { PrefixedIpInputProps } from "./components/PrefixedIpInput"; export type { RadioInputProps } from "./components/RadioInput"; export type { RowProps } from "./components/Row"; export type { ScrollableTableProps } from "./components/ScrollableTable";