Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(machines): update DHCP and discovery forms to use the new API #1116 #4389

31 changes: 31 additions & 0 deletions cypress/e2e/with-users/settings/dhcp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { generateMAASURL, generateName } from "../../utils";

context("Settings - DHCP Snippets", () => {
beforeEach(() => {
cy.login();
cy.addMachine();
cy.visit(generateMAASURL("/settings/dhcp/add"));
});

it("can add a DHCP snippet to a machine", () => {
const snippetName = generateName("dhcp-snippet");
cy.get("[data-testid='section-header-title']").contains("Settings");
cy.findByLabelText("Snippet name").type(snippetName);
cy.findByLabelText("Type").select("Machine");
cy.findByRole("button", { name: /Choose machine/ }).click();
// ensure the data has loaded
cy.findByRole("grid").should("have.attr", "aria-busy", "false");
cy.get("tbody").within(() => {
cy.findAllByRole("row").first().click();
});
cy.findByLabelText("DHCP snippet").type("ddns-update-style none;");
cy.findByRole("button", { name: "Save snippet" }).click();
// expect to be redirected to the list page
cy.findByLabelText("Search DHCP snippets").type(snippetName);
cy.findByRole("grid").within(() => {
cy.findByText(snippetName).should("be.visible");
cy.findByRole("button", { name: /Delete/ }).click();
cy.get("[data-testid='action-confirm']").click();
});
});
});
13 changes: 13 additions & 0 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import "@testing-library/cypress/add-commands";
import type { Result } from "axe-core";
import { nanoid } from "nanoid";
import { generateMAASURL, generateMac } from "../e2e/utils";
import type { A11yPageContext } from "./e2e";

Cypress.Commands.add("login", (options) => {
Expand Down Expand Up @@ -35,6 +37,17 @@ Cypress.Commands.add("loginNonAdmin", () => {
});
});

Cypress.Commands.add("addMachine", (hostname = `cypress-${nanoid()}`) => {
cy.visit(generateMAASURL("/machines"));
cy.get("[data-testid='add-hardware-dropdown'] button").click();
cy.get(".p-contextual-menu__link").contains("Machine").click();
cy.get("input[name='hostname']").type(hostname);
cy.get("input[name='pxe_mac']").type(generateMac());
cy.get("select[name='power_type']").select("manual").blur();
cy.get("button[type='submit']").click();
cy.get(`[data-testid='message']:contains(${hostname} added successfully.)`);
});

function logViolations(violations: Result[], pageContext: A11yPageContext) {
const divider =
"\n====================================================================================================\n";
Expand Down
1 change: 1 addition & 0 deletions cypress/support/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type A11yPageContext = { url?: string; title?: string };
declare global {
namespace Cypress {
interface Chainable {
addMachine(hostname?: string): void;
login(options?: {
username?: string;
password?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type Props = {
onDebounced: (debouncedText: string) => void;
searchText: string;
setSearchText: (searchText: string) => void;
} & Omit<SearchBoxProps, "externallyControlled" | "onChange" | "value">;
} & Omit<SearchBoxProps, "externallyControlled" | "onChange" | "value" | "ref">;

export const DEFAULT_DEBOUNCE_INTERVAL = 500;

Expand All @@ -22,6 +22,7 @@ const DebounceSearchBox = ({
onDebounced,
searchText,
setSearchText,
...props
}: Props): JSX.Element => {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const [debouncing, setDebouncing] = useState(false);
Expand All @@ -38,6 +39,7 @@ const DebounceSearchBox = ({
return (
<div className="debounce-search-box">
<SearchBox
{...props}
externallyControlled
onChange={(text: string) => {
setDebouncing(true);
Expand Down
58 changes: 43 additions & 15 deletions src/app/base/components/DhcpFormFields/DhcpFormFields.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import reduxToolkit from "@reduxjs/toolkit";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
Expand All @@ -16,17 +17,20 @@ import {
dhcpSnippetState as dhcpSnippetStateFactory,
machine as machineFactory,
machineState as machineStateFactory,
machineStateList as machineStateListFactory,
machineStateListGroup as machineStateListGroupFactory,
subnet as subnetFactory,
subnetState as subnetStateFactory,
rootState as rootStateFactory,
} from "testing/factories";

const mockStore = configureStore();

const machines = [machineFactory()];
describe("DhcpFormFields", () => {
let state: RootState;

beforeEach(() => {
jest.spyOn(reduxToolkit, "nanoid").mockReturnValue("123456");
state = rootStateFactory({
controller: controllerStateFactory({ loaded: true }),
device: deviceStateFactory({ loaded: true }),
Expand All @@ -49,11 +53,19 @@ describe("DhcpFormFields", () => {
loaded: true,
}),
machine: machineStateFactory({
items: [
machineFactory({
fqdn: "node2.maas",
items: machines,
lists: {
"123456": machineStateListFactory({
loading: false,
loaded: true,
groups: [
machineStateListGroupFactory({
items: [machines[0].system_id],
name: "Deployed",
}),
],
}),
],
},
loaded: true,
}),
subnet: subnetStateFactory({
Expand Down Expand Up @@ -113,7 +125,7 @@ describe("DhcpFormFields", () => {
screen.getByRole("alert", { name: Labels.LoadingData })
).toBeInTheDocument();
expect(
screen.queryByRole("combobox", { name: Labels.Entity })
screen.queryByRole("combobox", { name: Labels.AppliesTo })
).not.toBeInTheDocument();
});

Expand All @@ -136,7 +148,7 @@ describe("DhcpFormFields", () => {
screen.queryByRole("alert", { name: Labels.LoadingData })
).not.toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: Labels.Entity })
screen.getByRole("combobox", { name: Labels.AppliesTo })
).toBeInTheDocument();
});

Expand All @@ -154,15 +166,31 @@ describe("DhcpFormFields", () => {
);
// Set an initial type.
const typeSelect = screen.getByRole("combobox", { name: Labels.Type });
await userEvent.selectOptions(typeSelect, "machine");

await userEvent.selectOptions(typeSelect, "subnet");
await userEvent.selectOptions(
screen.getByRole("combobox", {
name: Labels.AppliesTo,
}),
"test.local"
);
// Select a machine. Value should get set.
const entitySelect = screen.getByRole("combobox", { name: Labels.Entity });
await userEvent.selectOptions(entitySelect, machine.system_id);
expect(entitySelect).toHaveValue(machine.system_id);

await userEvent.selectOptions(typeSelect, "machine");
await userEvent.click(
screen.getByRole("button", { name: /Choose machine/ })
);
await waitFor(() =>
expect(screen.getByRole("grid")).toHaveAttribute("aria-busy", "false")
);
within(screen.getByRole("grid")).getByText(machine.hostname).click();
expect(
screen.getByRole("button", { name: new RegExp(machine.hostname, "i") })
).toHaveAccessibleDescription(Labels.AppliesTo);
// Change the type. The select value should be cleared.
await userEvent.selectOptions(typeSelect, "subnet");
expect(entitySelect).toHaveValue("");
expect(
screen.getByRole("combobox", {
name: Labels.AppliesTo,
})
).toHaveValue("");
});
});
29 changes: 13 additions & 16 deletions src/app/base/components/DhcpFormFields/DhcpFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import { useFormikContext } from "formik";
import { useSelector } from "react-redux";

import MachineSelect from "./MachineSelect/MachineSelect";

import type { DHCPFormValues } from "app/base/components/DhcpForm/types";
import FormikField from "app/base/components/FormikField";
import controllerSelectors from "app/store/controller/selectors";
Expand Down Expand Up @@ -54,7 +56,7 @@ export enum Labels {
Description = "Description",
Disabled = "This snippet is disabled and will not be used by MAAS.",
Enabled = "Enabled",
Entity = "Applies to",
AppliesTo = "Applies to",
LoadingData = "Loading DHCP snippet data",
Name = "Snippet name",
Type = "Type",
Expand All @@ -73,12 +75,8 @@ export const DhcpFormFields = ({ editing }: Props): JSX.Element => {
const controllerLoaded = useSelector(controllerSelectors.loaded);
const deviceLoading = useSelector(deviceSelectors.loading);
const deviceLoaded = useSelector(deviceSelectors.loaded);
const machineLoading = useSelector(machineSelectors.loading);
const machineLoaded = useSelector(machineSelectors.loaded);
const isLoading =
subnetLoading || controllerLoading || deviceLoading || machineLoading;
const hasLoaded =
subnetLoaded && controllerLoaded && deviceLoaded && machineLoaded;
const isLoading = subnetLoading || controllerLoading || deviceLoading;
const hasLoaded = subnetLoaded && controllerLoaded && deviceLoaded;
const { enabled, type } = formikProps.values;
let models: ModelType[] | null;
switch (type) {
Expand All @@ -97,7 +95,6 @@ export const DhcpFormFields = ({ editing }: Props): JSX.Element => {
default:
models = null;
}

return (
<>
{editing && !enabled && (
Expand Down Expand Up @@ -134,21 +131,21 @@ export const DhcpFormFields = ({ editing }: Props): JSX.Element => {
{ value: "device", label: "Device" },
]}
/>
{type &&
{type === "machine" ? (
<FormikField component={MachineSelect} name="entity" />
) : (
type &&
(isLoading || !hasLoaded ? (
<Spinner aria-label={Labels.LoadingData} text="loading..." />
) : (
<FormikField
component={Select}
label={Labels.Entity}
label={Labels.AppliesTo}
name="entity"
options={
// This won't need to pass the empty array once this issue is fixed:
// https://github.com/canonical/react-components/issues/570
generateOptions(type, models) || []
}
options={generateOptions(type, models)}
/>
))}
))
)}
<FormikField
component={Textarea}
grow
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Formik } from "formik";

import MachineSelect, { Labels } from "./MachineSelect";

import { renderWithMockStore } from "testing/utils";

it("can open select box on click", async () => {
renderWithMockStore(
<Formik initialValues={{ machine: "" }} onSubmit={jest.fn()}>
<MachineSelect name="machine" />
</Formik>
);

expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: new RegExp(Labels.ChooseMachine, "i") })
);
expect(screen.getByRole("listbox")).toBeInTheDocument();
});

it("sets focus on the input field on open", async () => {
renderWithMockStore(
<Formik initialValues={{ machine: "" }} onSubmit={jest.fn()}>
<MachineSelect name="machine" />
</Formik>
);

await userEvent.click(
screen.getByRole("button", { name: new RegExp(Labels.ChooseMachine, "i") })
);
expect(
screen.getByPlaceholderText("Search by hostname, system ID or tags")
).toHaveFocus();
});
Loading