Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/app/switches/components/AddSwitch/AddSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import AddSwitch from "./AddSwitch";

import { imageResolvers } from "@/testing/resolvers/images";
import { switchResolvers } from "@/testing/resolvers/switches";
import {
mockSidePanel,
renderWithProviders,
screen,
setupMockServer,
userEvent,
waitFor,
} from "@/testing/utils";

const mockServer = setupMockServer(
switchResolvers.createSwitch.handler(),
imageResolvers.listAvailableSelections.handler()
);
const { mockClose } = await mockSidePanel();

describe("AddSwitch", () => {
it("closes the side panel when cancel is clicked", async () => {
renderWithProviders(<AddSwitch />);

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

expect(mockClose).toHaveBeenCalled();
});

it("calls create switch on save with mac address and image", async () => {
renderWithProviders(<AddSwitch />);

await userEvent.type(
await screen.findByRole("textbox", { name: /mac address/i }),
"aa:bb:cc:dd:ee:ff"
);
await userEvent.selectOptions(
screen.getByRole("combobox", { name: /image/i }),
"ubuntu/noble"
);

await userEvent.click(screen.getByRole("button", { name: /Save switch/i }));

await waitFor(() => {
expect(switchResolvers.createSwitch.resolved).toBeTruthy();
});
});

it("displays an error when create switch fails", async () => {
mockServer.use(
switchResolvers.createSwitch.error({
code: 409,
message: "A switch with this MAC address already exists.",
})
);
renderWithProviders(<AddSwitch />);

await userEvent.type(
await screen.findByRole("textbox", { name: /mac address/i }),
"aa:bb:cc:dd:ee:ff"
);
await userEvent.selectOptions(
screen.getByRole("combobox", { name: /image/i }),
"ubuntu/noble"
);

await userEvent.click(screen.getByRole("button", { name: /Save switch/i }));

await waitFor(() => {
expect(
screen.getByText(/A switch with this MAC address already exists./i)
).toBeInTheDocument();
});
});

it("shows validation error for invalid MAC address", async () => {
renderWithProviders(<AddSwitch />);

await userEvent.type(
await screen.findByRole("textbox", { name: /mac address/i }),
"not-a-mac"
);
await userEvent.click(screen.getByRole("button", { name: /Save switch/i }));

await waitFor(() => {
expect(screen.getByText(/Invalid MAC address/i)).toBeInTheDocument();
});
});
});
79 changes: 79 additions & 0 deletions src/app/switches/components/AddSwitch/AddSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Select, Spinner } from "@canonical/react-components";
import * as Yup from "yup";

import { useAvailableSelections } from "@/app/api/query/images";
import { useCreateSwitch } from "@/app/api/query/switches";
import type { CreateSwitchError, SwitchRequest } from "@/app/apiclient";
import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
import { useSidePanel } from "@/app/base/side-panel-context";
import { MAC_ADDRESS_REGEX } from "@/app/base/validation";

const AddSwitchSchema = Yup.object().shape({
mac_address: Yup.string()
.matches(MAC_ADDRESS_REGEX, "Invalid MAC address")
.required("MAC address is required"),
image: Yup.string().required("Image is required"),
});

const AddSwitch = () => {
const { closeSidePanel } = useSidePanel();
const createSwitch = useCreateSwitch();
const availableImages = useAvailableSelections();

const imageOptions = [
{ label: "Select image", value: "", disabled: true },
...(availableImages.data?.items ?? []).map((image) => ({
label: image.title,
value: `${image.os}/${image.release}`,
})),
];

if (availableImages.isPending) {
return <Spinner text="Loading..." />;
}

return (
<FormikForm<SwitchRequest, CreateSwitchError>
aria-label="Add switch"
errors={createSwitch.error}
initialValues={{
mac_address: "",
image: "",
}}
onCancel={closeSidePanel}
onSubmit={(values) => {
createSwitch.mutate({
body: {
mac_address: values.mac_address,
image: values.image,
},
});
}}
onSuccess={() => {
closeSidePanel();
}}
resetOnSave
saved={createSwitch.isSuccess}
saving={createSwitch.isPending}
submitLabel="Save switch"
validationSchema={AddSwitchSchema}
>
<FormikField
label="* MAC address"
name="mac_address"
placeholder="aa:bb:cc:dd:ee:ff"
type="text"
/>
<FormikField
component={Select}
label="* Image"
name="image"
options={imageOptions}
/>
{/* TODO: Implement Enable ZTP, ZTP script, DHCP option code and second MAC address fields when the API supports them. */}
</FormikForm>
);
};

export default AddSwitch;
1 change: 1 addition & 0 deletions src/app/switches/components/AddSwitch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./AddSwitch";
57 changes: 57 additions & 0 deletions src/app/switches/components/DeleteSwitch/DeleteSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import DeleteSwitch from "./DeleteSwitch";

import { switchResolvers } from "@/testing/resolvers/switches";
import {
mockSidePanel,
renderWithProviders,
screen,
setupMockServer,
userEvent,
waitFor,
} from "@/testing/utils";

const mockServer = setupMockServer(switchResolvers.deleteSwitch.handler());
const { mockClose } = await mockSidePanel();

describe("DeleteSwitch", () => {
const testSwitchId = 1;

it("closes the side panel when cancel is clicked", async () => {
renderWithProviders(<DeleteSwitch id={testSwitchId} />);

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

expect(mockClose).toHaveBeenCalled();
});

it("calls delete switch on confirm click", async () => {
renderWithProviders(<DeleteSwitch id={testSwitchId} />);

await userEvent.click(
screen.getByRole("button", { name: /Delete switch/i })
);

await waitFor(() => {
expect(switchResolvers.deleteSwitch.resolved).toBeTruthy();
});
});

it("displays error message when delete switch fails", async () => {
mockServer.use(
switchResolvers.deleteSwitch.error({
code: 404,
message: "Switch not found.",
})
);

renderWithProviders(<DeleteSwitch id={testSwitchId} />);

await userEvent.click(
screen.getByRole("button", { name: /Delete switch/i })
);

await waitFor(() => {
expect(screen.getByText(/Switch not found./i)).toBeInTheDocument();
});
});
});
36 changes: 36 additions & 0 deletions src/app/switches/components/DeleteSwitch/DeleteSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ReactElement } from "react";

import { useDeleteSwitch } from "@/app/api/query/switches";
import type { DeleteSwitchError } from "@/app/apiclient";
import ModelActionForm from "@/app/base/components/ModelActionForm";
import { useSidePanel } from "@/app/base/side-panel-context";

type DeleteSwitchProps = {
id: number;
};

const DeleteSwitch = ({ id }: DeleteSwitchProps): ReactElement => {
const { closeSidePanel } = useSidePanel();
const deleteSwitch = useDeleteSwitch();

return (
<ModelActionForm<object, DeleteSwitchError>
aria-label="Confirm switch deletion"
errors={deleteSwitch.error}
initialValues={{}}
modelType="switch"
onCancel={closeSidePanel}
onSubmit={() => {
deleteSwitch.mutate({ path: { switch_id: id } });
}}
onSuccess={() => {
closeSidePanel();
}}
saved={deleteSwitch.isSuccess}
saving={deleteSwitch.isPending}
submitLabel="Delete switch"
/>
);
};

export default DeleteSwitch;
1 change: 1 addition & 0 deletions src/app/switches/components/DeleteSwitch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./DeleteSwitch";
70 changes: 70 additions & 0 deletions src/app/switches/components/EditSwitch/EditSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import EditSwitch from "./EditSwitch";

import { imageResolvers } from "@/testing/resolvers/images";
import { switchResolvers } from "@/testing/resolvers/switches";
import {
mockSidePanel,
renderWithProviders,
screen,
setupMockServer,
userEvent,
waitFor,
} from "@/testing/utils";

const mockServer = setupMockServer(
switchResolvers.getSwitch.handler(),
switchResolvers.updateSwitch.handler(),
imageResolvers.listAvailableSelections.handler()
);
const { mockClose } = await mockSidePanel();

describe("EditSwitch", () => {
const testSwitchId = 1;

it("closes the side panel when cancel is clicked", async () => {
renderWithProviders(<EditSwitch id={testSwitchId} />);

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

expect(mockClose).toHaveBeenCalled();
});

it("calls update switch on save with image", async () => {
renderWithProviders(<EditSwitch id={testSwitchId} />);

await userEvent.selectOptions(
await screen.findByRole("combobox", { name: /image/i }),
"ubuntu/noble"
);

await userEvent.click(screen.getByRole("button", { name: /Save switch/i }));

await waitFor(() => {
expect(switchResolvers.updateSwitch.resolved).toBeTruthy();
});
});

it("displays error message when update switch fails", async () => {
mockServer.use(
switchResolvers.updateSwitch.error({
code: 500,
message: "Internal server error",
})
);

renderWithProviders(<EditSwitch id={testSwitchId} />);

await userEvent.selectOptions(
await screen.findByRole("combobox", { name: /image/i }),
"ubuntu/noble"
);

await userEvent.click(screen.getByRole("button", { name: /Save switch/i }));

await waitFor(() => {
expect(screen.getByText(/Internal server error/i)).toBeInTheDocument();
});
});
});
Loading
Loading