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");