Skip to content

Commit

Permalink
feat(subnets): Add form to reserve DHCP lease MAASENG-2975 (#5420)
Browse files Browse the repository at this point in the history
Co-authored-by: Peter Makowski <[email protected]>
  • Loading branch information
ndv99 and petermakowski authored May 7, 2024
1 parent 5292856 commit 1bad2df
Show file tree
Hide file tree
Showing 16 changed files with 512 additions and 14 deletions.
42 changes: 42 additions & 0 deletions src/app/base/components/PrefixedInput/PrefixedInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PrefixedInput aria-label="Limited input" immutableText="Some text" />
);

expect(
screen.getByRole("textbox", { name: "Limited input" })
).toBeInTheDocument();
});

it("sets the --immutable css variable to the provided immutable text", async () => {
const { rerender } = render(
<PrefixedInput aria-label="Limited input" immutableText="Some text" />
);

rerender(
<PrefixedInput aria-label="Limited input" immutableText="Some text" />
);

// Direct node access is needed here to check the CSS variable
expect(
screen.getByRole("textbox", { name: "Limited input" }).parentElement
?.parentElement
).toHaveStyle(`--immutable: "Some text";`);
});
55 changes: 55 additions & 0 deletions src/app/base/components/PrefixedInput/PrefixedInput.tsx
Original file line number Diff line number Diff line change
@@ -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<InputProps, "type"> & {
immutableText: string;
};

// TODO: Upstream to maas-react-components https://warthogs.atlassian.net/browse/MAASENG-3113
const PrefixedInput = ({ immutableText, ...props }: PrefixedInputProps) => {
const prefixedInputRef: RefObject<HTMLDivElement> = 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 (
<div className="prefixed-input" ref={prefixedInputRef}>
<Input
className={classNames("prefixed-input__input", props.className)}
type="text"
wrapperClassName={classNames(
"prefixed-input__wrapper",
props.wrapperClassName
)}
{...props}
/>
</div>
);
};

export default PrefixedInput;
16 changes: 16 additions & 0 deletions src/app/base/components/PrefixedInput/_index.scss
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
1 change: 1 addition & 0 deletions src/app/base/components/PrefixedInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./PrefixedInput";
69 changes: 69 additions & 0 deletions src/app/base/components/PrefixedIpInput/PrefixedIpInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Formik initialValues={{}} onSubmit={vi.fn()}>
<PrefixedIpInput cidr="10.0.0.0/24" name="ip" />
</Formik>
);
expect(screen.getByText("10.0.0.[1-254]")).toBeInTheDocument();
});

it("sets the --immutable css variable to the immutable octets of the subnet", () => {
render(
<Formik initialValues={{}} onSubmit={vi.fn()}>
<PrefixedIpInput aria-label="IP address" cidr="10.0.0.0/24" name="ip" />
</Formik>
);

// 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(
<Formik initialValues={{}} onSubmit={vi.fn()}>
<PrefixedIpInput cidr="10.0.0.0/24" name="ip" />
</Formik>
);

expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "[1-254]");
});

it("trims the immutable octets from a pasted IP address", async () => {
renderWithBrowserRouter(
<FormikForm initialValues={{ ip: "" }} onSubmit={vi.fn()}>
<FormikField cidr="10.0.0.0/24" component={PrefixedIpInput} name="ip" />
</FormikForm>
);

await userEvent.click(screen.getByRole("textbox"));
await userEvent.paste("10.0.0.1");

expect(screen.getByRole("textbox")).toHaveValue("1");
});
66 changes: 66 additions & 0 deletions src/app/base/components/PrefixedIpInput/PrefixedIpInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PrefixedInput
help={
<>
The available range in this subnet is{" "}
<code>
{immutable}.{editable}
</code>
</>
}
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;
1 change: 1 addition & 0 deletions src/app/base/components/PrefixedIpInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./PrefixedIpInput";
Original file line number Diff line number Diff line change
@@ -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(
<ReserveDHCPLease setSidePanelContent={vi.fn()} subnetId={1} />,
{ 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(
<ReserveDHCPLease setSidePanelContent={vi.fn()} subnetId={1} />,
{ 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(
<ReserveDHCPLease setSidePanelContent={setSidePanelContent} subnetId={1} />,
{ state }
);

await userEvent.click(screen.getByRole("button", { name: "Cancel" }));

expect(setSidePanelContent).toHaveBeenCalledWith(null);
});
Loading

0 comments on commit 1bad2df

Please sign in to comment.