-
Notifications
You must be signed in to change notification settings - Fork 52
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(subnets): Add form to reserve DHCP lease MAASENG-2975 (#5420)
Co-authored-by: Peter Makowski <[email protected]>
- Loading branch information
1 parent
5292856
commit 1bad2df
Showing
16 changed files
with
512 additions
and
14 deletions.
There are no files selected for viewing
42 changes: 42 additions & 0 deletions
42
src/app/base/components/PrefixedInput/PrefixedInput.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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";`); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from "./PrefixedInput"; |
69 changes: 69 additions & 0 deletions
69
src/app/base/components/PrefixedIpInput/PrefixedIpInput.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
66
src/app/base/components/PrefixedIpInput/PrefixedIpInput.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from "./PrefixedIpInput"; |
79 changes: 79 additions & 0 deletions
79
...pp/subnets/views/SubnetDetails/StaticDHCPLease/ReserveDHCPLease/ReserveDHCPLease.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
Oops, something went wrong.