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;