diff --git a/src/app/switches/components/AddSwitch/AddSwitch.test.tsx b/src/app/switches/components/AddSwitch/AddSwitch.test.tsx new file mode 100644 index 0000000000..e67d898e8e --- /dev/null +++ b/src/app/switches/components/AddSwitch/AddSwitch.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/src/app/switches/components/AddSwitch/AddSwitch.tsx b/src/app/switches/components/AddSwitch/AddSwitch.tsx new file mode 100644 index 0000000000..5426e5d45b --- /dev/null +++ b/src/app/switches/components/AddSwitch/AddSwitch.tsx @@ -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 ; + } + + return ( + + 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} + > + + + {/* TODO: Implement Enable ZTP, ZTP script, DHCP option code and second MAC address fields when the API supports them. */} + + ); +}; + +export default AddSwitch; diff --git a/src/app/switches/components/AddSwitch/index.ts b/src/app/switches/components/AddSwitch/index.ts new file mode 100644 index 0000000000..28636cb932 --- /dev/null +++ b/src/app/switches/components/AddSwitch/index.ts @@ -0,0 +1 @@ +export { default } from "./AddSwitch"; diff --git a/src/app/switches/components/DeleteSwitch/DeleteSwitch.test.tsx b/src/app/switches/components/DeleteSwitch/DeleteSwitch.test.tsx new file mode 100644 index 0000000000..fe01d23bd8 --- /dev/null +++ b/src/app/switches/components/DeleteSwitch/DeleteSwitch.test.tsx @@ -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(); + + await userEvent.click(screen.getByRole("button", { name: /Cancel/i })); + + expect(mockClose).toHaveBeenCalled(); + }); + + it("calls delete switch on confirm click", async () => { + renderWithProviders(); + + 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(); + + await userEvent.click( + screen.getByRole("button", { name: /Delete switch/i }) + ); + + await waitFor(() => { + expect(screen.getByText(/Switch not found./i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/switches/components/DeleteSwitch/DeleteSwitch.tsx b/src/app/switches/components/DeleteSwitch/DeleteSwitch.tsx new file mode 100644 index 0000000000..1a97c521f7 --- /dev/null +++ b/src/app/switches/components/DeleteSwitch/DeleteSwitch.tsx @@ -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 ( + + 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; diff --git a/src/app/switches/components/DeleteSwitch/index.ts b/src/app/switches/components/DeleteSwitch/index.ts new file mode 100644 index 0000000000..692ed4f329 --- /dev/null +++ b/src/app/switches/components/DeleteSwitch/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteSwitch"; diff --git a/src/app/switches/components/EditSwitch/EditSwitch.test.tsx b/src/app/switches/components/EditSwitch/EditSwitch.test.tsx new file mode 100644 index 0000000000..df63cf6bfa --- /dev/null +++ b/src/app/switches/components/EditSwitch/EditSwitch.test.tsx @@ -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(); + + await userEvent.click( + await screen.findByRole("button", { name: /Cancel/i }) + ); + + expect(mockClose).toHaveBeenCalled(); + }); + + it("calls update switch on save with image", async () => { + renderWithProviders(); + + 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(); + + 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(); + }); + }); +}); diff --git a/src/app/switches/components/EditSwitch/EditSwitch.tsx b/src/app/switches/components/EditSwitch/EditSwitch.tsx new file mode 100644 index 0000000000..f702bfdeef --- /dev/null +++ b/src/app/switches/components/EditSwitch/EditSwitch.tsx @@ -0,0 +1,88 @@ +import type { ReactElement } from "react"; + +import { + Notification as NotificationBanner, + Select, + Spinner, +} from "@canonical/react-components"; +import * as Yup from "yup"; + +import { useAvailableSelections } from "@/app/api/query/images"; +import { useGetSwitch, useUpdateSwitch } from "@/app/api/query/switches"; +import type { SwitchUpdateRequest, UpdateSwitchError } 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"; + +type EditSwitchProps = { + id: number; +}; + +const EditSwitchSchema = Yup.object().shape({ + image: Yup.string().required("Image is required"), +}); + +const EditSwitch = ({ id }: EditSwitchProps): ReactElement => { + const { closeSidePanel } = useSidePanel(); + const switchData = useGetSwitch({ path: { switch_id: id } }); + const eTag = switchData.data?.headers?.get("ETag"); + const updateSwitch = useUpdateSwitch(); + const availableImages = useAvailableSelections(); + + const imageOptions = [ + { label: "Select image", value: "", disabled: true }, + ...(availableImages.data?.items ?? []).map((image) => ({ + label: image.title, + value: `${image.os}/${image.release}`, + })), + ]; + + return ( + <> + {(switchData.isPending || availableImages.isPending) && ( + + )} + {switchData.isError && ( + + {switchData.error.message} + + )} + {switchData.isSuccess && + switchData.data && + !availableImages.isPending && ( + + aria-label="Edit switch" + errors={updateSwitch.error} + initialValues={{ + image: switchData.data.target_image ?? "", + }} + onCancel={closeSidePanel} + onSubmit={(values) => { + updateSwitch.mutate({ + headers: { ETag: eTag }, + body: { image: values.image }, + path: { switch_id: id }, + }); + }} + onSuccess={() => { + closeSidePanel(); + }} + saved={updateSwitch.isSuccess} + saving={updateSwitch.isPending} + submitLabel="Save switch" + validationSchema={EditSwitchSchema} + > + {/* TODO: Wire up name, mac_address, enable_ztp, ztp_script, dhcp_option_code, second_mac_address fields when the API supports them. */} + + + )} + + ); +}; + +export default EditSwitch; diff --git a/src/app/switches/components/EditSwitch/index.ts b/src/app/switches/components/EditSwitch/index.ts new file mode 100644 index 0000000000..3fc70a9391 --- /dev/null +++ b/src/app/switches/components/EditSwitch/index.ts @@ -0,0 +1 @@ +export { default } from "./EditSwitch"; diff --git a/src/app/switches/views/SwitchesList/SwitchesListHeader/SwitchesListHeader.test.tsx b/src/app/switches/components/SwitchesListHeader/SwitchesListHeader.test.tsx similarity index 100% rename from src/app/switches/views/SwitchesList/SwitchesListHeader/SwitchesListHeader.test.tsx rename to src/app/switches/components/SwitchesListHeader/SwitchesListHeader.test.tsx diff --git a/src/app/switches/views/SwitchesList/SwitchesListHeader/SwitchesListHeader.tsx b/src/app/switches/components/SwitchesListHeader/SwitchesListHeader.tsx similarity index 75% rename from src/app/switches/views/SwitchesList/SwitchesListHeader/SwitchesListHeader.tsx rename to src/app/switches/components/SwitchesListHeader/SwitchesListHeader.tsx index f349c0f1ee..10b42dd397 100644 --- a/src/app/switches/views/SwitchesList/SwitchesListHeader/SwitchesListHeader.tsx +++ b/src/app/switches/components/SwitchesListHeader/SwitchesListHeader.tsx @@ -3,7 +3,10 @@ import { useEffect, useState } from "react"; import { MainToolbar } from "@canonical/maas-react-components"; import { Button } from "@canonical/react-components"; +import AddSwitch from "../AddSwitch"; + import DebounceSearchBox from "@/app/base/components/DebounceSearchBox"; +import { useSidePanel } from "@/app/base/side-panel-context"; import type { SetSearchFilter } from "@/app/base/types"; type Props = { @@ -12,6 +15,7 @@ type Props = { }; const SwitchesListHeader = ({ searchFilter, setSearchFilter }: Props) => { + const { openSidePanel } = useSidePanel(); const [searchText, setSearchText] = useState(searchFilter); useEffect(() => { @@ -30,7 +34,14 @@ const SwitchesListHeader = ({ searchFilter, setSearchFilter }: Props) => { searchText={searchText} setSearchText={setSearchText} /> - + ); diff --git a/src/app/switches/views/SwitchesList/SwitchesListHeader/index.ts b/src/app/switches/components/SwitchesListHeader/index.ts similarity index 100% rename from src/app/switches/views/SwitchesList/SwitchesListHeader/index.ts rename to src/app/switches/components/SwitchesListHeader/index.ts diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.test.tsx b/src/app/switches/components/SwitchesTable/SwitchesTable.test.tsx similarity index 100% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.test.tsx rename to src/app/switches/components/SwitchesTable/SwitchesTable.test.tsx diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.tsx b/src/app/switches/components/SwitchesTable/SwitchesTable.tsx similarity index 100% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.tsx rename to src/app/switches/components/SwitchesTable/SwitchesTable.tsx diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/_index.scss b/src/app/switches/components/SwitchesTable/_index.scss similarity index 100% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/_index.scss rename to src/app/switches/components/SwitchesTable/_index.scss diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/index.ts b/src/app/switches/components/SwitchesTable/index.ts similarity index 100% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/index.ts rename to src/app/switches/components/SwitchesTable/index.ts diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx b/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx similarity index 78% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx rename to src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx index ae0bea8252..9927ffa5fd 100644 --- a/src/app/switches/views/SwitchesList/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx +++ b/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx @@ -3,13 +3,18 @@ import { useMemo } from "react"; import { Button, Icon } from "@canonical/react-components"; import type { ColumnDef } from "@tanstack/react-table"; +import DeleteSwitch from "../../DeleteSwitch"; +import EditSwitch from "../../EditSwitch"; + import DoubleRow from "@/app/base/components/DoubleRow"; import TableActions from "@/app/base/components/TableActions"; +import { useSidePanel } from "@/app/base/side-panel-context"; import type { SwitchItem } from "@/app/switches/types"; type SwitchColumnDef = ColumnDef; const useSwitchesTableColumns = (): SwitchColumnDef[] => { + const { openSidePanel } = useSidePanel(); return useMemo( () => [ { @@ -84,12 +89,27 @@ const useSwitchesTableColumns = (): SwitchColumnDef[] => { accessorKey: "id", enableSorting: false, header: "Actions", - cell: () => ( - undefined} onEdit={() => undefined} /> + cell: ({ row }) => ( + { + openSidePanel({ + component: DeleteSwitch, + title: "Delete switch", + props: { id: row.original.id }, + }); + }} + onEdit={() => { + openSidePanel({ + component: EditSwitch, + title: "Edit switch", + props: { id: row.original.id }, + }); + }} + /> ), }, ], - [] + [openSidePanel] ); }; diff --git a/src/app/switches/views/SwitchesList/SwitchesList.tsx b/src/app/switches/views/SwitchesList/SwitchesList.tsx index 197bc37cd9..34d59e99dd 100644 --- a/src/app/switches/views/SwitchesList/SwitchesList.tsx +++ b/src/app/switches/views/SwitchesList/SwitchesList.tsx @@ -1,10 +1,9 @@ import { useState, type ReactElement } from "react"; -import SwitchesListHeader from "./SwitchesListHeader"; -import SwitchesTable from "./components/SwitchesTable"; - import PageContent from "@/app/base/components/PageContent"; import { useWindowTitle } from "@/app/base/hooks"; +import SwitchesListHeader from "@/app/switches/components/SwitchesListHeader"; +import SwitchesTable from "@/app/switches/components/SwitchesTable"; const SwitchesList = (): ReactElement => { useWindowTitle("Switches");