Skip to content

Commit

Permalink
update DHCP add form #1116
Browse files Browse the repository at this point in the history
  • Loading branch information
petermakowski committed Sep 7, 2022
1 parent dfb398a commit 36f3ca3
Show file tree
Hide file tree
Showing 25 changed files with 514 additions and 101 deletions.
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
82 changes: 67 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 @@ -9,24 +10,51 @@ import { Labels } from "./DhcpFormFields";

import DhcpForm from "app/base/components/DhcpForm";
import type { RootState } from "app/store/root/types";
import { NodeStatus, NodeStatusCode } from "app/store/types/node";
import {
controllerState as controllerStateFactory,
deviceState as deviceStateFactory,
dhcpSnippet as dhcpSnippetFactory,
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({
actions: [],
architecture: "amd64/generic",
cpu_count: 4,
distro_series: "bionic",
extra_macs: [],
fqdn: "koala.example",
hostname: "koala",
ip_addresses: [],
memory: 8,
osystem: "ubuntu",
owner: "admin",
permissions: ["edit", "delete"],
physical_disk_count: 1,
pxe_mac: "00:11:22:33:44:55",
spaces: [],
status: NodeStatus.DEPLOYED,
status_code: NodeStatusCode.DEPLOYED,
status_message: "",
storage: 8,
system_id: "abc123",
}),
];
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 +77,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 +149,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 +172,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 +190,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: machine.hostname })
).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("");
});
});
38 changes: 22 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,30 @@ export const DhcpFormFields = ({ editing }: Props): JSX.Element => {
{ value: "device", label: "Device" },
]}
/>
{type &&
{type === "machine" ? (
<MachineSelect
onSelect={(machine) => {
if (machine) {
formikProps.setFieldValue("entity", machine.system_id);
} else {
formikProps.setFieldValue("entity", "");
}
}}
selected={formikProps.values.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,27 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

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

import { renderWithMockStore } from "testing/utils";

it("can open select box on click", async () => {
renderWithMockStore(<MachineSelect onSelect={jest.fn()} />);

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

it("sets focus on the input field on open", async () => {
renderWithMockStore(<MachineSelect onSelect={jest.fn()} />);

await userEvent.click(
screen.getByRole("button", { name: Labels.ChooseMachine })
);
expect(
screen.getByPlaceholderText("Search by hostname, system ID or tags")
).toHaveFocus();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";

import { Label, useId } from "@canonical/react-components";
import className from "classnames";
import { useDispatch } from "react-redux";

import SelectButton from "../../SelectButton";

import MachineSelectBox from "./MachineSelectBox";

import type { Machine } from "app/store/machine/types";
import { useFetchMachine } from "app/store/machine/utils/hooks";
import { actions as tagActions } from "app/store/tag";

export enum Labels {
AppliesTo = "Applies to",
Loading = "Loading...",
ChooseMachine = "Choose machine",
}

type Props = {
label?: string;
onSelect: (machine: Machine | null) => void;
selected?: Machine["system_id"] | null;
};

export const MachineSelect = ({
label = Labels.AppliesTo,
onSelect,
selected = null,
}: Props): JSX.Element => {
const dispatch = useDispatch();
const [isOpen, setIsOpen] = useState(false);
const selectId = useId();
const handleSelect = (machine: Machine | null) => {
setIsOpen(false);
onSelect(machine);
};

const { machine: selectedMachine } = useFetchMachine(selected, {
keepPreviousData: true,
});

useEffect(() => {
dispatch(tagActions.fetch());
}, [dispatch]);

return (
<div className="machine-select">
<Label id={selectId}>{label}</Label>
<SelectButton
aria-describedby={selectId}
aria-haspopup="listbox"
className="u-no-margin--bottom"
onClick={() => {
setIsOpen(!isOpen);
if (!isOpen) {
onSelect(null);
}
}}
>
{selectedMachine?.hostname || Labels.ChooseMachine}
</SelectButton>
<div
className={className("machine-select-box-wrapper", {
"machine-select-box-wrapper--is-open": isOpen,
})}
>
{isOpen ? <MachineSelectBox onSelect={handleSelect} /> : null}
</div>
</div>
);
};

export default MachineSelect;
Loading

0 comments on commit 36f3ca3

Please sign in to comment.