diff --git a/src/app/base/components/MainContentSection/MainContentSection.tsx b/src/app/base/components/MainContentSection/MainContentSection.tsx index 622ac9749d..c6882c31d8 100644 --- a/src/app/base/components/MainContentSection/MainContentSection.tsx +++ b/src/app/base/components/MainContentSection/MainContentSection.tsx @@ -2,6 +2,7 @@ import type { HTMLProps, ReactNode } from "react"; import { Col, Row } from "@canonical/react-components"; import type { ColSize } from "@canonical/react-components"; +import classNames from "classnames"; import NotificationList from "@/app/base/components/NotificationList"; import { COL_SIZES } from "@/app/base/constants"; @@ -35,7 +36,12 @@ const MainContentSection = ({ {sidebar} )} - + {!isNotificationListHidden && } {children} diff --git a/src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx b/src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx new file mode 100644 index 0000000000..b5f45b4cfb --- /dev/null +++ b/src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx @@ -0,0 +1,49 @@ +import ModelDeleteForm from "./ModelDeleteForm"; + +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +it("renders", () => { + renderWithBrowserRouter( + + ); + expect( + screen.getByText("Are you sure you want to delete this machine?") + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); +}); + +it("can confirm", async () => { + const onSubmit = vi.fn(); + renderWithBrowserRouter( + + ); + const submitBtn = screen.getByRole("button", { name: /delete/i }); + await userEvent.click(submitBtn); + expect(onSubmit).toHaveBeenCalled(); +}); + +it("can cancel", async () => { + const onCancel = vi.fn(); + renderWithBrowserRouter( + + ); + const cancelBtn = screen.getByRole("button", { name: /cancel/i }); + await userEvent.click(cancelBtn); + expect(onCancel).toHaveBeenCalled(); +}); diff --git a/src/app/base/components/ModelDeleteForm/ModelDeleteForm.tsx b/src/app/base/components/ModelDeleteForm/ModelDeleteForm.tsx new file mode 100644 index 0000000000..95f5ee2948 --- /dev/null +++ b/src/app/base/components/ModelDeleteForm/ModelDeleteForm.tsx @@ -0,0 +1,43 @@ +import { Col, Row } from "@canonical/react-components"; + +import type { Props as FormikFormProps } from "@/app/base/components/FormikForm/FormikForm"; +import FormikForm from "@/app/base/components/FormikForm/FormikForm"; +import type { EmptyObject } from "@/app/base/types"; + +type Props = { + modelType: string; + message?: string; +} & FormikFormProps; + +const ModelDeleteForm = ({ + modelType, + message, + submitAppearance = "negative", + submitLabel = "Delete", + initialValues = {}, + ...props +}: Props) => { + return ( + + + +

+ {message + ? message + : `Are you sure you want to delete this ${modelType}?`} +

+ + This action is permanent and can not be undone. + + +
+
+ ); +}; + +export default ModelDeleteForm; diff --git a/src/app/base/components/ModelDeleteForm/index.ts b/src/app/base/components/ModelDeleteForm/index.ts new file mode 100644 index 0000000000..1717de3f26 --- /dev/null +++ b/src/app/base/components/ModelDeleteForm/index.ts @@ -0,0 +1 @@ +export { default } from "./ModelDeleteForm"; diff --git a/src/app/base/components/NetworkActionRow/NetworkActionRow.test.tsx b/src/app/base/components/NetworkActionRow/NetworkActionRow.test.tsx index e19fb5f197..b4ed6dc117 100644 --- a/src/app/base/components/NetworkActionRow/NetworkActionRow.test.tsx +++ b/src/app/base/components/NetworkActionRow/NetworkActionRow.test.tsx @@ -11,7 +11,6 @@ import { rootState as rootStateFactory, } from "@/testing/factories"; import { - userEvent, screen, renderWithBrowserRouter, expectTooltipOnHover, @@ -37,7 +36,6 @@ describe("NetworkActionRow", () => { const store = mockStore(state); renderWithBrowserRouter( { }, ]} node={state.machine.items[0]} - setExpanded={vi.fn()} />, { route: "/machine/abc123", store } ); @@ -54,34 +51,11 @@ describe("NetworkActionRow", () => { }); describe("add physical", () => { - it("sets the state to show the form when clicking the button", async () => { - const store = mockStore(state); - const setExpanded = vi.fn(); - renderWithBrowserRouter( - , - { route: "/machine/abc123", store } - ); - await userEvent.click( - screen.getByRole("button", { name: "Add interface" }) - ); - expect(setExpanded).toHaveBeenCalledWith({ - content: ExpandedState.ADD_PHYSICAL, - }); - }); - it("disables the button when networking is disabled", async () => { state.machine.items[0].status = NodeStatus.DEPLOYED; const store = mockStore(state); renderWithBrowserRouter( - , + , { route: "/machine/abc123", store } ); const addInterfaceButton = screen.getByRole("button", { @@ -93,21 +67,5 @@ describe("NetworkActionRow", () => { "Network can't be modified for this machine." ); }); - - it("disables the button when the form is expanded", () => { - state.machine.items[0].status = NodeStatus.DEPLOYED; - const store = mockStore(state); - renderWithBrowserRouter( - , - { route: "/machine/abc123", store } - ); - expect( - screen.getByRole("button", { name: "Add interface" }) - ).toBeDisabled(); - }); }); }); diff --git a/src/app/base/components/NetworkActionRow/NetworkActionRow.tsx b/src/app/base/components/NetworkActionRow/NetworkActionRow.tsx index 742c055295..40400ff92f 100644 --- a/src/app/base/components/NetworkActionRow/NetworkActionRow.tsx +++ b/src/app/base/components/NetworkActionRow/NetworkActionRow.tsx @@ -1,11 +1,18 @@ import type { ReactNode } from "react"; import { Button, Col, List, Row, Tooltip } from "@canonical/react-components"; +import { useLocation } from "react-router-dom"; -import type { Expanded, SetExpanded } from "../NodeNetworkTab/NodeNetworkTab"; import { ExpandedState } from "../NodeNetworkTab/NodeNetworkTab"; +import type { + Selected, + SetSelected, +} from "@/app/base/components/node/networking/types"; import { useIsAllNetworkingDisabled } from "@/app/base/hooks"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import { DeviceSidePanelViews } from "@/app/devices/constants"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import type { Node } from "@/app/store/types/node"; type Action = { @@ -15,38 +22,61 @@ type Action = { }; type Props = { - expanded: Expanded | null; extraActions?: Action[]; node: Node; rightContent?: ReactNode; - setExpanded: SetExpanded; + selected?: Selected[]; + setSelected?: SetSelected; }; export const NETWORK_DISABLED_MESSAGE = "Network can't be modified for this machine."; const NetworkActionRow = ({ - expanded, extraActions, node, rightContent, - setExpanded, + selected, + setSelected, }: Props): JSX.Element | null => { const isAllNetworkingDisabled = useIsAllNetworkingDisabled(node); + const { setSidePanelContent } = useSidePanel(); + const { pathname } = useLocation(); + const isMachinesPage = pathname.startsWith("/machine"); const actions: Action[] = [ { - disabled: [ - [isAllNetworkingDisabled, NETWORK_DISABLED_MESSAGE], - // Disable the button when the form is visible. - [expanded?.content === ExpandedState.ADD_PHYSICAL], - ], + disabled: [[isAllNetworkingDisabled, NETWORK_DISABLED_MESSAGE]], label: "Add interface", state: ExpandedState.ADD_PHYSICAL, }, ...(extraActions || []), ]; + const handleButtonClick = (state: ExpandedState) => { + const expandedStateMap: Partial void>> = { + [ExpandedState.ADD_PHYSICAL]: isMachinesPage + ? () => + setSidePanelContent({ + view: MachineSidePanelViews.ADD_INTERFACE, + extras: { systemId: node.system_id }, + }) + : () => + setSidePanelContent({ view: DeviceSidePanelViews.ADD_INTERFACE }), + [ExpandedState.ADD_BOND]: () => + setSidePanelContent({ + view: MachineSidePanelViews.ADD_BOND, + extras: { systemId: node.system_id, selected: selected, setSelected }, + }), + [ExpandedState.ADD_BRIDGE]: () => + setSidePanelContent({ + view: MachineSidePanelViews.ADD_BRIDGE, + extras: { systemId: node.system_id, selected: selected, setSelected }, + }), + }; + return expandedStateMap[state]?.(); + }; + const buttons = actions.map((item) => { // Check if there is any reason to disable the button. const [disabled, tooltip] = @@ -55,9 +85,7 @@ const NetworkActionRow = ({ diff --git a/src/app/base/components/SSHKeyForm/SSHKeyForm.tsx b/src/app/base/components/SSHKeyForm/SSHKeyForm.tsx index 2e92ad6052..c1bfd406c9 100644 --- a/src/app/base/components/SSHKeyForm/SSHKeyForm.tsx +++ b/src/app/base/components/SSHKeyForm/SSHKeyForm.tsx @@ -61,7 +61,7 @@ export const SSHKeyForm = ({ cols, ...props }: Props): JSX.Element => { validationSchema={SSHKeySchema} {...props} > - + ); }; diff --git a/src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx b/src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx index cbc18ddbe6..37947b9dce 100644 --- a/src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx +++ b/src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx @@ -1,31 +1,22 @@ import { ExternalLink } from "@canonical/maas-react-components"; import { Col, Row, Select, Textarea } from "@canonical/react-components"; -import type { ColSize } from "@canonical/react-components"; import { useFormikContext } from "formik"; import type { SSHKeyFormValues } from "../types"; import FormikField from "@/app/base/components/FormikField"; import TooltipButton from "@/app/base/components/TooltipButton"; -import { COL_SIZES } from "@/app/base/constants"; import docsUrls from "@/app/base/docsUrls"; -type Props = { - cols?: number; -}; - -export const SSHKeyFormFields = ({ - cols = COL_SIZES.TOTAL, -}: Props): JSX.Element => { +export const SSHKeyFormFields = (): JSX.Element => { const { values } = useFormikContext(); const { protocol } = values; const uploadSelected = protocol === "upload"; - const colSize = cols / 2; return ( <> - + )} - +

Before you can deploy a machine you must import at least one public SSH key into MAAS, so the deployed machine can be accessed. diff --git a/src/app/base/components/TableActions/TableActions.tsx b/src/app/base/components/TableActions/TableActions.tsx index 0b3f578ed8..1168a861be 100644 --- a/src/app/base/components/TableActions/TableActions.tsx +++ b/src/app/base/components/TableActions/TableActions.tsx @@ -9,6 +9,7 @@ type Props = { copyValue?: string; deleteDisabled?: boolean; deleteTooltip?: string | null; + deletePath?: string; editDisabled?: boolean; editPath?: string; editTooltip?: string | null; @@ -22,6 +23,7 @@ const TableActions = ({ clearTooltip, copyValue, deleteDisabled, + deletePath, deleteTooltip, editDisabled, editPath, @@ -48,16 +50,18 @@ const TableActions = ({ )} - {onDelete && ( + {(onDelete || deletePath) && ( diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx index 26975bd6f3..fbfc26c929 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx @@ -652,7 +652,6 @@ const AvailableStorageTable = ({ )} {isMachine && canEditStorage && ( { + const setSidePanelContent = vi.fn(); + beforeAll(() => { + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); + }); it("disables create volume group button with tooltip if selected devices are not eligible", async () => { const selected = [ diskFactory({ @@ -42,7 +54,6 @@ describe("BulkActions", () => { }); renderWithBrowserRouter( { }); renderWithBrowserRouter( { }), }); renderWithBrowserRouter( - , - { state } + , + { + state, + } ); expect(screen.getByTestId("vmware-bulk-actions")).toBeInTheDocument(); @@ -136,7 +143,6 @@ describe("BulkActions", () => { }); renderWithBrowserRouter( { }); renderWithBrowserRouter( { ).not.toBeDisabled(); }); - it("can render the create datastore form", () => { + it("can trigger the create datastore sidepanel", async () => { + const datastore = diskFactory({ + filesystem: fsFactory({ fstype: "vmfs6" }), + }); + const selected = diskFactory({ filesystem: null, partitions: null }); const state = rootStateFactory({ machine: machineStateFactory({ items: [ machineDetailsFactory({ + detected_storage_layout: StorageLayout.VMFS6, + disks: [datastore, selected], system_id: "abc123", }), ], @@ -199,30 +210,40 @@ describe("BulkActions", () => { }); renderWithBrowserRouter( , { state } ); - // Ensure correct form inputs are shown - expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Size" })).toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: "Filesystem" }) - ).toBeInTheDocument(); - expect( + await userEvent.click( screen.getByRole("button", { name: "Create datastore" }) - ).toBeInTheDocument(); + ); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: MachineSidePanelViews.CREATE_DATASTORE, + }) + ); }); - it("can render the create RAID form", () => { + it("can trigger the create RAID sidepanel", async () => { + const selected = [ + diskFactory({ + filesystem: null, + type: DiskTypes.VIRTUAL, + }), + diskFactory({ + filesystem: null, + type: DiskTypes.VIRTUAL, + }), + ]; const state = rootStateFactory({ machine: machineStateFactory({ items: [ machineDetailsFactory({ + detected_storage_layout: StorageLayout.FLAT, + disks: selected, system_id: "abc123", }), ], @@ -233,40 +254,38 @@ describe("BulkActions", () => { }); renderWithBrowserRouter( , { state } ); - // Ensure the correct form inputs are shown - expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument(); - expect( - screen.getByRole("combobox", { name: "RAID level" }) - ).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Size" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Tags" })).toBeInTheDocument(); - expect( - screen.getByRole("combobox", { name: "Filesystem" }) - ).toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: "Mount point" }) - ).toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: "Mount options" }) - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Create RAID" }) - ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Create RAID" })); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: MachineSidePanelViews.CREATE_RAID, + }) + ); }); - it("can render the create volume group form", () => { + it("can trigger the create volume group sidepanel", async () => { + const selected = [ + diskFactory({ + filesystem: null, + type: DiskTypes.VIRTUAL, + }), + diskFactory({ + filesystem: null, + type: DiskTypes.VIRTUAL, + }), + ]; const state = rootStateFactory({ machine: machineStateFactory({ items: [ machineDetailsFactory({ + detected_storage_layout: StorageLayout.FLAT, + disks: selected, system_id: "abc123", }), ], @@ -277,33 +296,35 @@ describe("BulkActions", () => { }); renderWithBrowserRouter( , { state } ); - // Ensure the correct form inputs are shown - expect( + await userEvent.click( screen.getByRole("button", { name: "Create volume group" }) - ).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Size" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Type" })).toBeInTheDocument(); + ); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: MachineSidePanelViews.CREATE_VOLUME_GROUP, + }) + ); }); - it("can render the update datastore form", () => { + it("can trigger the update datastore sidepanel", async () => { const datastore = diskFactory({ filesystem: fsFactory({ fstype: "vmfs6" }), }); + const selected = diskFactory({ filesystem: null, partitions: null }); const state = rootStateFactory({ machine: machineStateFactory({ items: [ machineDetailsFactory({ + detected_storage_layout: StorageLayout.VMFS6, + disks: [datastore, selected], system_id: "abc123", - disks: [datastore], }), ], statuses: machineStatusesFactory({ @@ -311,25 +332,23 @@ describe("BulkActions", () => { }), }), }); + renderWithBrowserRouter( , { state } ); - // Ensure the correct form inputs are shown - expect( - screen.getByRole("combobox", { name: "Datastore" }) - ).toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: "Mount point" }) - ).toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: "Size to add" }) - ).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Add to existing datastore" }) + ); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: MachineSidePanelViews.UPDATE_DATASTORE, + }) + ); }); }); diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/BulkActions.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/BulkActions.tsx index 1e2bfe0b62..d239a3e694 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/BulkActions.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/BulkActions.tsx @@ -3,11 +3,8 @@ import { useSelector } from "react-redux"; import { BulkAction } from "../AvailableStorageTable"; -import CreateDatastore from "./CreateDatastore"; -import CreateRaid from "./CreateRaid"; -import CreateVolumeGroup from "./CreateVolumeGroup"; -import UpdateDatastore from "./UpdateDatastore"; - +import { useSidePanel } from "@/app/base/side-panel-context"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import machineSelectors from "@/app/store/machine/selectors"; import type { Machine } from "@/app/store/machine/types"; import { isMachineDetails } from "@/app/store/machine/utils"; @@ -22,14 +19,12 @@ import { } from "@/app/store/utils"; type Props = { - bulkAction: BulkAction | null; selected: (Disk | Partition)[]; setBulkAction: (bulkAction: BulkAction | null) => void; systemId: Machine["system_id"]; }; const BulkActions = ({ - bulkAction, selected, setBulkAction, systemId, @@ -37,51 +32,12 @@ const BulkActions = ({ const machine = useSelector((state: RootState) => machineSelectors.getById(state, systemId) ); + const { setSidePanelContent } = useSidePanel(); if (!isMachineDetails(machine)) { return null; } - if (bulkAction === BulkAction.CREATE_DATASTORE) { - return ( - setBulkAction(null)} - selected={selected} - systemId={systemId} - /> - ); - } - - if (bulkAction === BulkAction.CREATE_RAID) { - return ( - setBulkAction(null)} - selected={selected} - systemId={systemId} - /> - ); - } - - if (bulkAction === BulkAction.CREATE_VOLUME_GROUP) { - return ( - setBulkAction(null)} - selected={selected} - systemId={systemId} - /> - ); - } - - if (bulkAction === BulkAction.UPDATE_DATASTORE) { - return ( - setBulkAction(null)} - selected={selected} - systemId={systemId} - /> - ); - } - if (isVMWareLayout(machine.detected_storage_layout)) { const hasDatastores = machine.disks.some((disk) => isDatastore(disk.filesystem) @@ -115,7 +71,13 @@ const BulkActions = ({ @@ -128,7 +90,13 @@ const BulkActions = ({ @@ -158,7 +126,13 @@ const BulkActions = ({ @@ -175,7 +149,13 @@ const BulkActions = ({ diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateDatastore/CreateDatastore.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateDatastore/CreateDatastore.tsx index 67a68beaed..12f8ec3956 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateDatastore/CreateDatastore.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateDatastore/CreateDatastore.tsx @@ -10,7 +10,6 @@ import { import { useDispatch, useSelector } from "react-redux"; import * as Yup from "yup"; -import FormCard from "@/app/base/components/FormCard"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; import { useMachineDetailsForm } from "@/app/machines/hooks"; @@ -78,78 +77,76 @@ export const CreateDatastore = ({ if (isMachineDetails(machine)) { return ( - - - allowUnchanged - cleanup={machineActions.cleanup} - errors={errors} - initialValues={{ - name: getInitialName(machine.disks), - }} - onCancel={closeForm} - onSaveAnalytics={{ - action: "Create datastore", - category: "Machine storage", - label: "Create datastore", - }} - onSubmit={(values: CreateDatastoreValues) => { - const [blockDeviceIds, partitionIds] = - splitDiskPartitionIds(selected); - const params = { - name: values.name, - systemId, - ...(blockDeviceIds.length > 0 && { blockDeviceIds }), - ...(partitionIds.length > 0 && { partitionIds }), - }; - dispatch(machineActions.createVmfsDatastore(params)); - }} - saved={saved} - saving={saving} - submitLabel="Create datastore" - validationSchema={CreateDatastoreSchema} - > - - - - - - Name - Size - Device type + + allowUnchanged + cleanup={machineActions.cleanup} + errors={errors} + initialValues={{ + name: getInitialName(machine.disks), + }} + onCancel={closeForm} + onSaveAnalytics={{ + action: "Create datastore", + category: "Machine storage", + label: "Create datastore", + }} + onSubmit={(values: CreateDatastoreValues) => { + const [blockDeviceIds, partitionIds] = + splitDiskPartitionIds(selected); + const params = { + name: values.name, + systemId, + ...(blockDeviceIds.length > 0 && { blockDeviceIds }), + ...(partitionIds.length > 0 && { partitionIds }), + }; + dispatch(machineActions.createVmfsDatastore(params)); + }} + saved={saved} + saving={saving} + submitLabel="Create datastore" + validationSchema={CreateDatastoreSchema} + > + + +
+ + + Name + Size + Device type + + + + {selected.map((device) => ( + + {device.name} + {formatSize(device.size)} + {formatType(device)} - - - {selected.map((device) => ( - - {device.name} - {formatSize(device.size)} - {formatType(device)} - - ))} - -
- - - - - - -
- -
+ ))} + + + + + + + + + + ); } return null; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaid.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaid.tsx index 6ea67ecc95..0dc79039c3 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaid.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaid.tsx @@ -3,7 +3,6 @@ import * as Yup from "yup"; import CreateRaidFields from "./CreateRaidFields"; -import FormCard from "@/app/base/components/FormCard"; import FormikForm from "@/app/base/components/FormikForm"; import { useMachineDetailsForm } from "@/app/machines/hooks"; import { actions as machineActions } from "@/app/store/machine"; @@ -87,66 +86,64 @@ export const CreateRaid = ({ if (isMachineDetails(machine)) { return ( - - - allowUnchanged - cleanup={machineActions.cleanup} - errors={errors} - initialValues={{ - blockDeviceIds: initialBlockDevices, - fstype: "", - level: DiskTypes.RAID_0, - mountOptions: "", - mountPoint: "", - name: getInitialName(machine.disks), - partitionIds: initialPartitions, - spareBlockDeviceIds: [], - sparePartitionIds: [], - tags: [], - }} - onCancel={closeForm} - onSaveAnalytics={{ - action: "Create RAID", - category: "Machine storage", - label: "Create RAID", - }} - onSubmit={(values) => { - const { - blockDeviceIds, - fstype, + + allowUnchanged + cleanup={machineActions.cleanup} + errors={errors} + initialValues={{ + blockDeviceIds: initialBlockDevices, + fstype: "", + level: DiskTypes.RAID_0, + mountOptions: "", + mountPoint: "", + name: getInitialName(machine.disks), + partitionIds: initialPartitions, + spareBlockDeviceIds: [], + sparePartitionIds: [], + tags: [], + }} + onCancel={closeForm} + onSaveAnalytics={{ + action: "Create RAID", + category: "Machine storage", + label: "Create RAID", + }} + onSubmit={(values) => { + const { + blockDeviceIds, + fstype, + level, + mountOptions, + mountPoint, + name, + partitionIds, + spareBlockDeviceIds, + sparePartitionIds, + tags, + } = values; + dispatch( + machineActions.createRaid({ level, - mountOptions, - mountPoint, name, - partitionIds, - spareBlockDeviceIds, - sparePartitionIds, + systemId, tags, - } = values; - dispatch( - machineActions.createRaid({ - level, - name, - systemId, - tags, - ...(fstype && { fstype }), - ...(fstype && mountOptions && { mountOptions }), - ...(fstype && mountPoint && { mountPoint }), - ...(blockDeviceIds.length > 0 && { blockDeviceIds }), - ...(partitionIds.length > 0 && { partitionIds }), - ...(spareBlockDeviceIds.length > 0 && { spareBlockDeviceIds }), - ...(sparePartitionIds.length > 0 && { sparePartitionIds }), - }) - ); - }} - saved={saved} - saving={saving} - submitLabel="Create RAID" - validationSchema={CreateRaidSchema} - > - - - + ...(fstype && { fstype }), + ...(fstype && mountOptions && { mountOptions }), + ...(fstype && mountPoint && { mountPoint }), + ...(blockDeviceIds.length > 0 && { blockDeviceIds }), + ...(partitionIds.length > 0 && { partitionIds }), + ...(spareBlockDeviceIds.length > 0 && { spareBlockDeviceIds }), + ...(sparePartitionIds.length > 0 && { sparePartitionIds }), + }) + ); + }} + saved={saved} + saving={saving} + submitLabel="Create RAID" + validationSchema={CreateRaidSchema} + > + + ); } return null; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaidFields/CreateRaidFields.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaidFields/CreateRaidFields.tsx index 1507cb24bc..f96dd77117 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaidFields/CreateRaidFields.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid/CreateRaidFields/CreateRaidFields.tsx @@ -123,7 +123,7 @@ export const CreateRaidFields = ({ return ( <> - + component={Select} @@ -160,7 +160,7 @@ export const CreateRaidFields = ({ /> - + diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateVolumeGroup/CreateVolumeGroup.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateVolumeGroup/CreateVolumeGroup.tsx index 06244954f5..48e22c53c2 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateVolumeGroup/CreateVolumeGroup.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateVolumeGroup/CreateVolumeGroup.tsx @@ -10,7 +10,6 @@ import { import { useDispatch, useSelector } from "react-redux"; import * as Yup from "yup"; -import FormCard from "@/app/base/components/FormCard"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; import { useMachineDetailsForm } from "@/app/machines/hooks"; @@ -72,77 +71,75 @@ export const CreateVolumeGroup = ({ if (isMachineDetails(machine)) { return ( - - - allowUnchanged - cleanup={machineActions.cleanup} - errors={errors} - initialValues={{ - name: getInitialName(machine.disks), - }} - onCancel={closeForm} - onSaveAnalytics={{ - action: "Create volume group", - category: "Machine storage", - label: "Create volume group", - }} - onSubmit={(values: CreateVolumeGroupValues) => { - const [blockDeviceIds, partitionIds] = - splitDiskPartitionIds(selected); - const params = { - name: values.name, - systemId, - ...(blockDeviceIds.length > 0 && { blockDeviceIds }), - ...(partitionIds.length > 0 && { partitionIds }), - }; - dispatch(machineActions.createVolumeGroup(params)); - }} - saved={saved} - saving={saving} - submitLabel="Create volume group" - validationSchema={CreateVolumeGroupSchema} - > - - - - - - Name - Size - Device type + + allowUnchanged + cleanup={machineActions.cleanup} + errors={errors} + initialValues={{ + name: getInitialName(machine.disks), + }} + onCancel={closeForm} + onSaveAnalytics={{ + action: "Create volume group", + category: "Machine storage", + label: "Create volume group", + }} + onSubmit={(values: CreateVolumeGroupValues) => { + const [blockDeviceIds, partitionIds] = + splitDiskPartitionIds(selected); + const params = { + name: values.name, + systemId, + ...(blockDeviceIds.length > 0 && { blockDeviceIds }), + ...(partitionIds.length > 0 && { partitionIds }), + }; + dispatch(machineActions.createVolumeGroup(params)); + }} + saved={saved} + saving={saving} + submitLabel="Create volume group" + validationSchema={CreateVolumeGroupSchema} + > + + +
+ + + Name + Size + Device type + + + + {selected.map((device) => ( + + {device.name} + {formatSize(device.size)} + {formatType(device)} - - - {selected.map((device) => ( - - {device.name} - {formatSize(device.size)} - {formatType(device)} - - ))} - -
- - - - - - -
- -
+ ))} + + + + + + + + + + ); } return null; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastore.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastore.tsx index c97406cc8b..22da89bc82 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastore.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastore.tsx @@ -3,7 +3,6 @@ import * as Yup from "yup"; import UpdateDatastoreFields from "./UpdateDatastoreFields"; -import FormCard from "@/app/base/components/FormCard"; import FormikForm from "@/app/base/components/FormikForm"; import { useMachineDetailsForm } from "@/app/machines/hooks"; import { actions as machineActions } from "@/app/store/machine"; @@ -58,42 +57,40 @@ export const UpdateDatastore = ({ } return ( - - - allowUnchanged - cleanup={machineActions.cleanup} - errors={errors} - initialValues={{ - datastore: datastores[0].id, - }} - onCancel={closeForm} - onSaveAnalytics={{ - action: "Update datastore", - category: "Machine storage", - label: "Add to datastore", - }} - onSubmit={(values: UpdateDatastoreValues) => { - const [blockDeviceIds, partitionIds] = - splitDiskPartitionIds(selected); - const params = { - systemId, - vmfsDatastoreId: values.datastore, - ...(blockDeviceIds.length > 0 && { blockDeviceIds }), - ...(partitionIds.length > 0 && { partitionIds }), - }; - dispatch(machineActions.updateVmfsDatastore(params)); - }} - saved={saved} - saving={saving} - submitLabel="Add to datastore" - validationSchema={UpdateDatastoreSchema} - > - - - + + allowUnchanged + cleanup={machineActions.cleanup} + errors={errors} + initialValues={{ + datastore: datastores[0].id, + }} + onCancel={closeForm} + onSaveAnalytics={{ + action: "Update datastore", + category: "Machine storage", + label: "Add to datastore", + }} + onSubmit={(values: UpdateDatastoreValues) => { + const [blockDeviceIds, partitionIds] = + splitDiskPartitionIds(selected); + const params = { + systemId, + vmfsDatastoreId: values.datastore, + ...(blockDeviceIds.length > 0 && { blockDeviceIds }), + ...(partitionIds.length > 0 && { partitionIds }), + }; + dispatch(machineActions.updateVmfsDatastore(params)); + }} + saved={saved} + saving={saving} + submitLabel="Add to datastore" + validationSchema={UpdateDatastoreSchema} + > + + ); } return null; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastoreFields/UpdateDatastoreFields.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastoreFields/UpdateDatastoreFields.tsx index 99bb0ce858..ec77051d83 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastoreFields/UpdateDatastoreFields.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore/UpdateDatastoreFields/UpdateDatastoreFields.tsx @@ -36,7 +36,7 @@ export const UpdateDatastoreFields = ({ return ( - + @@ -56,7 +56,7 @@ export const UpdateDatastoreFields = ({
- + - - aria-label="Add special filesystem" - cleanup={machineActions.cleanup} - errors={errors} - initialValues={{ - fstype: "", - mountOptions: "", - mountPoint: "", - }} - onCancel={closeForm} - onSaveAnalytics={{ - action: "Add special filesystem", - category: "Machine storage", - label: "Mount", - }} - onSubmit={(values) => { - dispatch(machineActions.cleanup()); - const params = { - fstype: values.fstype, - mountOptions: values.mountOptions, - mountPoint: values.mountPoint, - systemId: machine.system_id, - }; - dispatch(machineActions.mountSpecial(params)); - }} - saved={saved} - saving={saving} - submitLabel="Mount" - validationSchema={AddSpecialFilesystemSchema} - > - - - - - - - - - + + aria-label="Add special filesystem" + cleanup={machineActions.cleanup} + errors={errors} + initialValues={{ + fstype: "", + mountOptions: "", + mountPoint: "", + }} + onCancel={closeForm} + onSaveAnalytics={{ + action: "Add special filesystem", + category: "Machine storage", + label: "Mount", + }} + onSubmit={(values) => { + dispatch(machineActions.cleanup()); + const params = { + fstype: values.fstype, + mountOptions: values.mountOptions, + mountPoint: values.mountPoint, + systemId: machine.system_id, + }; + dispatch(machineActions.mountSpecial(params)); + }} + saved={saved} + saving={saving} + submitLabel="Mount" + validationSchema={AddSpecialFilesystemSchema} + > + + + + + + + + ); }; diff --git a/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.test.tsx b/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.test.tsx index 56e16bfef8..057a1400fd 100644 --- a/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.test.tsx +++ b/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.test.tsx @@ -240,34 +240,6 @@ it("disables the action menu if node is a machine and storage can't be edited", expect(screen.getByRole("button", { name: /Take action/ })).toBeDisabled(); }); -it("can show an add special filesystem form if node is a machine", async () => { - const machine = machineDetailsFactory({ system_id: "abc123" }); - const state = rootStateFactory({ - machine: machineStateFactory({ - items: [machine], - statuses: machineStatusesFactory({ - abc123: machineStatusFactory(), - }), - }), - }); - const store = mockStore(state); - render( - - - - - - - - ); - - await userEvent.click(screen.getByTestId("add-special-fs-button")); - - expect( - screen.getByRole("form", { name: "Add special filesystem" }) - ).toBeInTheDocument(); -}); - it("can remove a disk's filesystem if node is a machine", async () => { const filesystem = fsFactory({ mount_point: "/disk-fs/path" }); const disk = diskFactory({ filesystem, partitions: [] }); diff --git a/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.tsx b/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.tsx index 988410c20f..7d5a475af4 100644 --- a/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.tsx +++ b/src/app/base/components/node/StorageTables/FilesystemsTable/FilesystemsTable.tsx @@ -4,10 +4,10 @@ import { Button, MainTable, Tooltip } from "@canonical/react-components"; import type { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; import { useDispatch } from "react-redux"; -import AddSpecialFilesystem from "./AddSpecialFilesystem"; - import TableActionsDropdown from "@/app/base/components/TableActionsDropdown"; import ActionConfirm from "@/app/base/components/node/ActionConfirm"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import type { ControllerDetails } from "@/app/store/controller/types"; import { actions as machineActions } from "@/app/store/machine"; import type { MachineDetails } from "@/app/store/machine/types"; @@ -127,10 +127,9 @@ const FilesystemsTable = ({ node, }: Props): JSX.Element | null => { const dispatch = useDispatch(); - const [addSpecialFormOpen, setAddSpecialFormOpen] = useState(false); const [expanded, setExpanded] = useState(null); const isMachine = nodeIsMachine(node); - const closeAddSpecialForm = () => setAddSpecialFormOpen(false); + const { setSidePanelContent } = useSidePanel(); const rows = node.disks.reduce((rows, disk) => { const diskFs = disk.filesystem; @@ -346,19 +345,22 @@ const FilesystemsTable = ({ No filesystems defined.

)} - {canEditStorage && !addSpecialFormOpen && ( + {canEditStorage && ( )} - {isMachine && addSpecialFormOpen && ( - - )} ); }; diff --git a/src/app/base/side-panel-context.tsx b/src/app/base/side-panel-context.tsx index ef31ffee92..801a41e240 100644 --- a/src/app/base/side-panel-context.tsx +++ b/src/app/base/side-panel-context.tsx @@ -13,6 +13,8 @@ import { DomainListSidePanelViews, type DomainListSidePanelContent, } from "@/app/domains/views/DomainsList/constants"; +import { ImageSidePanelViews } from "@/app/images/constants"; +import type { ImageSidePanelContent } from "@/app/images/types"; import { KVMSidePanelViews } from "@/app/kvm/constants"; import type { KVMSidePanelContent } from "@/app/kvm/types"; import { MachineSidePanelViews } from "@/app/machines/constants"; @@ -61,6 +63,7 @@ export type SidePanelContent = | NetworkDiscoverySidePanelContent | VLANDetailsSidePanelContent | FabricDetailsSidePanelContent + | ImageSidePanelContent | SubnetDetailsSidePanelContent | SpaceDetailsSidePanelContent | null; @@ -89,6 +92,7 @@ export const SidePanelViews = { ...NetworkDiscoverySidePanelViews, ...VLANDetailsSidePanelViews, ...FabricDetailsSidePanelViews, + ...ImageSidePanelViews, ...SubnetDetailsSidePanelViews, ...SpaceDetailsSidePanelViews, } as const; diff --git a/src/app/devices/components/DeviceNetworkForms/DeviceNetworkForms.test.tsx b/src/app/devices/components/DeviceNetworkForms/DeviceNetworkForms.test.tsx new file mode 100644 index 0000000000..6ce3424c08 --- /dev/null +++ b/src/app/devices/components/DeviceNetworkForms/DeviceNetworkForms.test.tsx @@ -0,0 +1,43 @@ +import configureStore from "redux-mock-store"; + +import DeviceNetworkForms from "./DeviceNetworkForms"; + +import { DeviceSidePanelViews } from "@/app/devices/constants"; +import type { DeviceSidePanelContent } from "@/app/devices/types"; +import type { RootState } from "@/app/store/root/types"; +import { + deviceDetails as deviceDetailsFactory, + deviceState as deviceStateFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen } from "@/testing/utils"; + +const mockStore = configureStore(); +let state: RootState; + +beforeEach(() => { + state = rootStateFactory({ + device: deviceStateFactory({ + items: [deviceDetailsFactory({ system_id: "abc123" })], + }), + }); +}); + +it("renders a form when appropriate sidepanel view is provided", () => { + const store = mockStore(state); + const sidePanelContent: DeviceSidePanelContent = { + view: DeviceSidePanelViews.ADD_INTERFACE, + }; + renderWithBrowserRouter( + , + { store } + ); + + expect( + screen.getByRole("form", { name: /add interface/i }) + ).toBeInTheDocument(); +}); diff --git a/src/app/devices/components/DeviceNetworkForms/DeviceNetworkForms.tsx b/src/app/devices/components/DeviceNetworkForms/DeviceNetworkForms.tsx new file mode 100644 index 0000000000..ed329fae2c --- /dev/null +++ b/src/app/devices/components/DeviceNetworkForms/DeviceNetworkForms.tsx @@ -0,0 +1,66 @@ +import { useCallback } from "react"; + +import type { SidePanelContentTypes } from "@/app/base/side-panel-context"; +import { DeviceSidePanelViews } from "@/app/devices/constants"; +import AddInterface from "@/app/devices/views/DeviceDetails/DeviceNetwork/AddInterface"; +import RemoveInterface from "@/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface"; +import EditInterface from "@/app/devices/views/DeviceDetails/DeviceNetwork/EditInterface"; +import type { Device } from "@/app/store/device/types"; + +type Props = SidePanelContentTypes & { + systemId: Device["system_id"]; +}; + +const DeviceNetworkForms = ({ + systemId, + sidePanelContent, + setSidePanelContent, +}: Props): JSX.Element | null => { + const clearSidePanelContent = useCallback( + () => setSidePanelContent(null), + [setSidePanelContent] + ); + + if (!sidePanelContent) { + return null; + } + + const linkId = + sidePanelContent.extras && "linkId" in sidePanelContent.extras + ? sidePanelContent.extras.linkId + : null; + + const nicId = + sidePanelContent.extras && "nicId" in sidePanelContent.extras + ? sidePanelContent.extras.nicId + : null; + + switch (sidePanelContent.view) { + case DeviceSidePanelViews.ADD_INTERFACE: + return ( + + ); + case DeviceSidePanelViews.EDIT_INTERFACE: + return ( + + ); + case DeviceSidePanelViews.REMOVE_INTERFACE: + return nicId ? ( + + ) : null; + + default: + return null; + } +}; + +export default DeviceNetworkForms; diff --git a/src/app/devices/components/DeviceNetworkForms/index.ts b/src/app/devices/components/DeviceNetworkForms/index.ts new file mode 100644 index 0000000000..027974ba28 --- /dev/null +++ b/src/app/devices/components/DeviceNetworkForms/index.ts @@ -0,0 +1 @@ +export { default } from "./DeviceNetworkForms"; diff --git a/src/app/devices/constants.ts b/src/app/devices/constants.ts index 65b9af379f..9f84a5587c 100644 --- a/src/app/devices/constants.ts +++ b/src/app/devices/constants.ts @@ -7,6 +7,9 @@ export const DeviceActionHeaderViews = { export const DeviceNonActionHeaderViews = { ADD_DEVICE: ["deviceNonActionForm", "addDevice"], + ADD_INTERFACE: ["deviceNonActionForm", "addInterface"], + EDIT_INTERFACE: ["deviceNonActionForm", "editInterface"], + REMOVE_INTERFACE: ["deviceNonActionForm", "removeInterface"], } as const; export const DeviceSidePanelViews = { diff --git a/src/app/devices/types.ts b/src/app/devices/types.ts index 06199f9cb1..ef17d02281 100644 --- a/src/app/devices/types.ts +++ b/src/app/devices/types.ts @@ -2,9 +2,14 @@ import type { ValueOf } from "@canonical/react-components"; import type { SidePanelContent, SetSidePanelContent } from "@/app/base/types"; import type { DeviceSidePanelViews } from "@/app/devices/constants"; +import type { NetworkInterface, NetworkLink } from "@/app/store/types/node"; export type DeviceSidePanelContent = SidePanelContent< - ValueOf + ValueOf, + { + linkId?: NetworkLink["id"]; + nicId?: NetworkInterface["id"]; + } >; export type DeviceSetSidePanelContent = diff --git a/src/app/devices/views/DeviceDetails/DeviceDetails.tsx b/src/app/devices/views/DeviceDetails/DeviceDetails.tsx index e3eaa2ef3d..f1bee7f986 100644 --- a/src/app/devices/views/DeviceDetails/DeviceDetails.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceDetails.tsx @@ -15,6 +15,7 @@ import { useGetURLId } from "@/app/base/hooks/urls"; import { useSidePanel } from "@/app/base/side-panel-context"; import urls from "@/app/base/urls"; import DeviceHeaderForms from "@/app/devices/components/DeviceHeaderForms"; +import DeviceNetworkForms from "@/app/devices/components/DeviceNetworkForms"; import { actions as deviceActions } from "@/app/store/device"; import deviceSelectors from "@/app/store/device/selectors"; import { DeviceMeta } from "@/app/store/device/types"; @@ -54,51 +55,90 @@ const DeviceDetails = (): JSX.Element => { } const base = urls.devices.device.index(null); - return ( - - } - sidePanelContent={ - sidePanelContent && - device && ( - - ) - } - sidePanelTitle={getSidePanelTitle(device?.fqdn || "", sidePanelContent)} - > - {device && ( - - } - path={getRelativeRoute(urls.devices.device.summary(null), base)} - /> - } - path={getRelativeRoute(urls.devices.device.network(null), base)} - /> - } - path={getRelativeRoute( - urls.devices.device.configuration(null), - base + return device ? ( + + + } + sidePanelContent={ + sidePanelContent && + device && ( + + ) + } + sidePanelTitle={getSidePanelTitle( + device?.fqdn || "", + sidePanelContent )} - /> - } - path="/" - /> - - )} - + > + + + } + path={getRelativeRoute(urls.devices.device.summary(null), base)} + /> + + } + sidePanelContent={ + sidePanelContent && ( + + ) + } + sidePanelTitle={getSidePanelTitle( + device?.fqdn || "", + sidePanelContent + )} + > + + + } + path={getRelativeRoute(urls.devices.device.network(null), base)} + /> + + } + sidePanelContent={null} + sidePanelTitle={null} + > + + + } + path={getRelativeRoute(urls.devices.device.configuration(null), base)} + /> + } + path="/" + /> + + ) : ( + <> ); }; diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/AddInterface/AddInterface.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/AddInterface/AddInterface.tsx index beb984188b..8f74eaaf76 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/AddInterface/AddInterface.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/AddInterface/AddInterface.tsx @@ -3,7 +3,6 @@ import { useDispatch, useSelector } from "react-redux"; import InterfaceForm from "../InterfaceForm"; -import FormCard from "@/app/base/components/FormCard"; import { useCycled, useScrollOnRender } from "@/app/base/hooks"; import { actions as deviceActions } from "@/app/store/device"; import deviceSelectors from "@/app/store/device/selectors"; @@ -44,28 +43,27 @@ const AddInterface = ({ closeForm, systemId }: Props): JSX.Element => { } return (
- - { - resetCreatedInterface(); - dispatch(deviceActions.cleanup()); - const payload = preparePayload({ - ...values, - system_id: device.system_id, - }) as CreateInterfaceParams; - dispatch(deviceActions.createInterface(payload)); - }} - saved={saved} - saving={creatingInterface} - systemId={systemId} - /> - + { + resetCreatedInterface(); + dispatch(deviceActions.cleanup()); + const payload = preparePayload({ + ...values, + system_id: device.system_id, + }) as CreateInterfaceParams; + dispatch(deviceActions.createInterface(payload)); + }} + saved={saved} + saving={creatingInterface} + systemId={systemId} + />
); }; diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetwork.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetwork.tsx index 777eba71e7..df056bbdf4 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetwork.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetwork.tsx @@ -1,14 +1,11 @@ import { Spinner } from "@canonical/react-components"; import { useSelector } from "react-redux"; -import AddInterface from "./AddInterface"; import DeviceNetworkTable from "./DeviceNetworkTable"; -import EditInterface from "./EditInterface"; import DHCPTable from "@/app/base/components/DHCPTable"; import NetworkActionRow from "@/app/base/components/NetworkActionRow"; import NodeNetworkTab from "@/app/base/components/NodeNetworkTab"; -import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import { useWindowTitle } from "@/app/base/hooks"; import deviceSelectors from "@/app/store/device/selectors"; import { DeviceMeta } from "@/app/store/device/types"; @@ -37,19 +34,7 @@ const DeviceNetwork = ({ systemId }: Props): JSX.Element => { return ( <> ( - - )} - addInterface={(_, setExpanded) => ( - setExpanded(null)} - systemId={systemId} - /> - )} + actions={() => } aria-label={Label.Title} dhcpTable={() => ( { node={device} /> )} - expandedForm={(expanded, setExpanded) => { - if (expanded?.content === ExpandedState.EDIT) { - return ( - setExpanded(null)} - linkId={expanded?.linkId} - nicId={expanded?.nicId} - systemId={systemId} - /> - ); - } - return null; - }} - interfaceTable={(expanded, setExpanded) => ( - - )} + interfaceTable={() => } /> ); diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.test.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.test.tsx index eeed74cb20..f0b0c82d5d 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.test.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.test.tsx @@ -2,7 +2,6 @@ import configureStore from "redux-mock-store"; import DeviceNetworkTable from "./DeviceNetworkTable"; -import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import type { RootState } from "@/app/store/root/types"; import { NetworkInterfaceTypes } from "@/app/store/types/enum"; import { @@ -49,27 +48,17 @@ describe("DeviceNetworkTable", () => { it("displays a spinner when loading", () => { state.device.items = []; const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); + renderWithBrowserRouter(, { + store, + }); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); it("displays a table when loaded", () => { const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); + renderWithBrowserRouter(, { + store, + }); expect(screen.getByRole("grid")).toBeInTheDocument(); }); @@ -98,14 +87,9 @@ describe("DeviceNetworkTable", () => { }), ]; const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); + renderWithBrowserRouter(, { + store, + }); expect(screen.getByTestId("ip-mode")).toHaveTextContent("Unconfigured"); }); @@ -135,76 +119,15 @@ describe("DeviceNetworkTable", () => { }), ]; const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); + renderWithBrowserRouter(, { + store, + }); expect( screen.getByRole("link", { name: "subnet-cidr" }) ).toBeInTheDocument(); expect(screen.getByTestId("ip-address")).toHaveTextContent("1.2.3.99"); }); - it("expands a row when a matching link is found", () => { - state.device.items = [ - deviceDetailsFactory({ - interfaces: [ - deviceInterfaceFactory({ - discovered: null, - links: [networkLinkFactory(), networkLinkFactory({ id: 2 })], - name: "alias", - type: NetworkInterfaceTypes.ALIAS, - }), - ], - system_id: "abc123", - }), - ]; - const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); - const rows = screen.getAllByRole("row"); - expect(rows[1]).not.toHaveClass("is-active"); - expect(rows[2]).toHaveClass("is-active"); - }); - - it("expands a row when a matching nic is found", () => { - state.device.items = [ - deviceDetailsFactory({ - interfaces: [ - deviceInterfaceFactory({ - id: 2, - discovered: null, - links: [], - name: "eth0", - type: NetworkInterfaceTypes.PHYSICAL, - }), - ], - system_id: "abc123", - }), - ]; - const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); - const rows = screen.getAllByRole("row"); - expect(rows[1]).toHaveClass("is-active"); - }); - it("displays an empty table description", () => { state.device.items = [ deviceDetailsFactory({ @@ -213,14 +136,9 @@ describe("DeviceNetworkTable", () => { }), ]; const store = mockStore(state); - renderWithBrowserRouter( - , - { store } - ); + renderWithBrowserRouter(, { + store, + }); expect(screen.getByText("No interfaces available.")).toBeInTheDocument(); }); }); diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.tsx index f6c1af443c..5b71b15b7d 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/DeviceNetworkTable.tsx @@ -1,16 +1,8 @@ import { MainTable, Spinner } from "@canonical/react-components"; import type { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; -import classNames from "classnames"; import { useSelector } from "react-redux"; -import RemoveInterface from "./RemoveInterface"; - import MacAddressDisplay from "@/app/base/components/MacAddressDisplay"; -import type { - Expanded, - SetExpanded, -} from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; -import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import TableHeader from "@/app/base/components/TableHeader"; import TableMenu from "@/app/base/components/TableMenu"; import SubnetColumn from "@/app/base/components/node/networking/SubnetColumn"; @@ -19,7 +11,10 @@ import { useIsAllNetworkingDisabled, useTableSort, } from "@/app/base/hooks"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import { useSidePanel } from "@/app/base/side-panel-context"; import { SortDirection } from "@/app/base/types"; +import { DeviceSidePanelViews } from "@/app/devices/constants"; import deviceSelectors from "@/app/store/device/selectors"; import type { Device, DeviceMeta } from "@/app/store/device/types"; import { isDeviceDetails } from "@/app/store/device/utils"; @@ -59,8 +54,6 @@ type NetworkRow = Omit & { type SortKey = keyof NetworkRowSortData; type Props = { - expanded: Expanded | null; - setExpanded: SetExpanded; systemId: Device[DeviceMeta.PK]; }; @@ -70,15 +63,14 @@ const getSortValue = (sortKey: SortKey, row: NetworkRow) => { }; const generateRow = ( - expanded: Expanded | null, fabrics: Fabric[], isAllNetworkingDisabled: boolean, link: NetworkLink | null, device: Device, nic: NetworkInterface | null, - setExpanded: SetExpanded, subnets: Subnet[], - vlans: VLAN[] + vlans: VLAN[], + setSidePanelContent: SetSidePanelContent ): NetworkRow | null => { if (link && !nic) { [nic] = getLinkInterface(device, link); @@ -97,14 +89,9 @@ const generateRow = ( link ); const typeDisplay = getInterfaceTypeText(device, nic, link); - const isExpanded = - !!expanded && - ((link && expanded.linkId === link.id) || - (!link && expanded.nicId === nic?.id)); + return { - className: classNames("p-table__row", { - "is-active": isExpanded, - }), + className: "p-table__row", columns: [ { content: {nic.mac_address}, @@ -131,19 +118,17 @@ const generateRow = ( { children: `Edit ${typeDisplay}`, onClick: () => - setExpanded({ - content: ExpandedState.EDIT, - linkId: link?.id, - nicId: nic?.id, + setSidePanelContent({ + view: DeviceSidePanelViews.EDIT_INTERFACE, + extras: { linkId: link?.id, nicId: nic?.id }, }), }, { children: `Remove ${typeDisplay}`, onClick: () => - setExpanded({ - content: ExpandedState.REMOVE, - linkId: link?.id, - nicId: nic?.id, + setSidePanelContent({ + view: DeviceSidePanelViews.REMOVE_INTERFACE, + extras: { linkId: link?.id, nicId: nic?.id }, }), }, ]} @@ -153,18 +138,6 @@ const generateRow = ( ), }, ], - expanded: isExpanded, - expandedContent: ( -
- {expanded?.content === ExpandedState.REMOVE && ( - setExpanded(null)} - nicId={nic?.id} - systemId={device.system_id} - /> - )} -
- ), key: name, sortData: { ip_address: @@ -177,13 +150,12 @@ const generateRow = ( }; const generateRows = ( - expanded: Expanded | null, fabrics: Fabric[], isAllNetworkingDisabled: boolean, device: Device, - setExpanded: (expanded: Expanded | null) => void, subnets: Subnet[], - vlans: VLAN[] + vlans: VLAN[], + setSidePanelContent: SetSidePanelContent ): NetworkRow[] => { if (!isDeviceDetails(device)) { return []; @@ -196,15 +168,14 @@ const generateRows = ( nic: NetworkInterface | null ) => generateRow( - expanded, fabrics, isAllNetworkingDisabled, link, device, nic, - setExpanded, subnets, - vlans + vlans, + setSidePanelContent ); if (nic.links.length === 0) { const row = createRow(null, nic); @@ -223,17 +194,14 @@ const generateRows = ( return rows; }; -const DeviceNetworkTable = ({ - expanded, - setExpanded, - systemId, -}: Props): JSX.Element => { +const DeviceNetworkTable = ({ systemId }: Props): JSX.Element => { const device = useSelector((state: RootState) => deviceSelectors.getById(state, systemId) ); const fabrics = useSelector(fabricSelectors.all); const subnets = useSelector(subnetSelectors.all); const vlans = useSelector(vlanSelectors.all); + const { setSidePanelContent } = useSidePanel(); const isAllNetworkingDisabled = useIsAllNetworkingDisabled(device); const { currentSort, sortRows, updateSort } = useTableSort< NetworkRow, @@ -254,13 +222,12 @@ const DeviceNetworkTable = ({ } const rows = generateRows( - expanded, fabrics, isAllNetworkingDisabled, device, - setExpanded, subnets, - vlans + vlans, + setSidePanelContent ); const sortedRows = sortRows(rows); return ( diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.test.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.test.tsx index a979214634..d6fd9ba306 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.test.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.test.tsx @@ -14,7 +14,7 @@ import { deviceStatuses as deviceStatusesFactory, rootState as rootStateFactory, } from "@/testing/factories"; -import { userEvent, screen, renderWithMockStore } from "@/testing/utils"; +import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; const mockStore = configureStore(); @@ -37,21 +37,17 @@ describe("RemoveInterface", () => { }); it("sends an analytics event and closes the form when saved", () => { - const closeExpanded = vi.fn(); + const closeForm = vi.fn(); const useSendMock = vi.spyOn(analyticsHooks, "useSendAnalyticsWhen"); // Mock interface successfully being deleted. vi.spyOn(baseHooks, "useCycled").mockReturnValue([true, () => null]); const store = mockStore(state); - renderWithMockStore( - , + renderWithBrowserRouter( + , { store } ); - expect(closeExpanded).toHaveBeenCalled(); + expect(closeForm).toHaveBeenCalled(); expect(useSendMock.mock.calls[0]).toEqual([ true, "Device network", @@ -84,24 +80,24 @@ describe("RemoveInterface", () => { }), ]; const store = mockStore(state); - renderWithMockStore( - , + renderWithBrowserRouter( + , { store } ); - expect(screen.getByTestId("error-message")).toHaveTextContent( - "Delete interface error for this device" - ); + expect( + screen.getByText("Delete interface error for this device") + ).toBeInTheDocument(); }); it("correctly dispatches an action to delete an interface", async () => { const store = mockStore(state); - renderWithMockStore( - , + renderWithBrowserRouter( + , { store } ); - await userEvent.click(screen.getByTestId("confirm-delete")); + await userEvent.click(screen.getByRole("button", { name: /remove/i })); const expectedAction = deviceActions.deleteInterface({ interface_id: 1, diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx index 9319709561..f79e36e8d1 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx @@ -1,14 +1,9 @@ import { useEffect } from "react"; -import { - ActionButton, - Button, - Col, - Notification, - Row, -} from "@canonical/react-components"; +import { Notification } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; +import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; import { useCycled, useSendAnalyticsWhen } from "@/app/base/hooks"; import { actions as deviceActions } from "@/app/store/device"; import deviceSelectors from "@/app/store/device/selectors"; @@ -21,13 +16,13 @@ import type { RootState } from "@/app/store/root/types"; import { formatErrors } from "@/app/utils"; type Props = { - closeExpanded: () => void; + closeForm: () => void; nicId: DeviceNetworkInterface["id"]; systemId: Device[DeviceMeta.PK]; }; const RemoveInterface = ({ - closeExpanded, + closeForm, nicId, systemId, }: Props): JSX.Element => { @@ -49,12 +44,12 @@ const RemoveInterface = ({ ); useEffect(() => { if (deletedInterface) { - closeExpanded(); + closeForm(); } - }, [closeExpanded, deletedInterface]); + }, [closeForm, deletedInterface]); return ( - + <> {deleteInterfaceError ? ( @@ -62,36 +57,24 @@ const RemoveInterface = ({ ) : null} - -

- Warning - Are you sure you want to remove this interface? -

- - - - { - dispatch(deviceActions.cleanup()); - dispatch( - deviceActions.deleteInterface({ - interface_id: nicId, - system_id: systemId, - }) - ); - }} - type="button" - > - Remove - - -
+ { + dispatch(deviceActions.cleanup()); + dispatch( + deviceActions.deleteInterface({ + interface_id: nicId, + system_id: systemId, + }) + ); + }} + saving={deletingInterface} + submitLabel="Remove" + /> + ); }; diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/EditInterface/EditInterface.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/EditInterface/EditInterface.tsx index f006c8c751..806a5840de 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/EditInterface/EditInterface.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/EditInterface/EditInterface.tsx @@ -3,9 +3,6 @@ import { useDispatch, useSelector } from "react-redux"; import InterfaceForm from "../InterfaceForm"; -import EditInterfaceTable from "./EditInterfaceTable"; - -import FormCard from "@/app/base/components/FormCard"; import { useCycled } from "@/app/base/hooks"; import { actions as deviceActions } from "@/app/store/device"; import deviceSelectors from "@/app/store/device/selectors"; @@ -57,33 +54,30 @@ const EditInterface = ({ return ; } return ( - - - { - resetUpdatedInterface(); - dispatch(deviceActions.cleanup()); - const payload = preparePayload({ - ...values, - interface_id: nic.id, - system_id: device.system_id, - }) as UpdateInterfaceParams; - dispatch(deviceActions.updateInterface(payload)); - }} - saved={saved} - saving={updatingInterface} - showTitles - systemId={systemId} - /> - + { + resetUpdatedInterface(); + dispatch(deviceActions.cleanup()); + const payload = preparePayload({ + ...values, + interface_id: nic.id, + system_id: device.system_id, + }) as UpdateInterfaceParams; + dispatch(deviceActions.updateInterface(payload)); + }} + saved={saved} + saving={updatingInterface} + showTitles + systemId={systemId} + /> ); }; diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/InterfaceForm/InterfaceFormFields/InterfaceFormFields.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/InterfaceForm/InterfaceFormFields/InterfaceFormFields.tsx index 8dafa33d3b..35a7e480ff 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/InterfaceForm/InterfaceFormFields/InterfaceFormFields.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/InterfaceForm/InterfaceFormFields/InterfaceFormFields.tsx @@ -31,13 +31,13 @@ const InterfaceFormFields = ({ showTitles = false }: Props): JSX.Element => { {showTitles ? null : ( <> - {nameField} + {nameField}
)} - + {showTitles ? ( <>

{ - + {showTitles ? (

(); +let state: RootState; + +beforeEach(() => { + state = rootStateFactory({ + bootresource: bootResourceStateFactory({ + resources: [ + resourceFactory({ + arch: "amd64", + complete: true, + name: "ubuntu/focal", + title: "20.04 LTS", + }), + ], + }), + config: configStateFactory({ + items: [ + configFactory({ + name: ConfigNames.COMMISSIONING_DISTRO_SERIES, + value: "focal", + }), + ], + }), + }); +}); + +it("renders a form when appropriate sidepanel view is provided", () => { + const store = mockStore(state); + const sidePanelContent: ImageSidePanelContent = { + view: ImageSidePanelViews.CHANGE_SOURCE, + extras: { hasSources: false }, + }; + renderWithBrowserRouter( + , + { store } + ); + + expect( + screen.getByRole("form", { name: "Choose source" }) + ).toBeInTheDocument(); +}); diff --git a/src/app/images/components/ImagesForms/ImagesForms.tsx b/src/app/images/components/ImagesForms/ImagesForms.tsx new file mode 100644 index 0000000000..740375914f --- /dev/null +++ b/src/app/images/components/ImagesForms/ImagesForms.tsx @@ -0,0 +1,55 @@ +import { useCallback } from "react"; + +import DeleteImageConfirm from "../ImagesTable/DeleteImageConfirm"; + +import type { SidePanelContentTypes } from "@/app/base/side-panel-context"; +import { ImageSidePanelViews } from "@/app/images/constants"; +import ChangeSource from "@/app/images/views/ImageList/SyncedImages/ChangeSource"; + +type Props = SidePanelContentTypes & {}; + +const ImagesForms = ({ + sidePanelContent, + setSidePanelContent, +}: Props): JSX.Element | null => { + const clearSidePanelContent = useCallback( + () => setSidePanelContent(null), + [setSidePanelContent] + ); + + if (!sidePanelContent) { + return null; + } + + const hasSources = + sidePanelContent.extras && "hasSources" in sidePanelContent.extras + ? sidePanelContent.extras.hasSources + : false; + + switch (sidePanelContent.view) { + case ImageSidePanelViews.CHANGE_SOURCE: + return ( + clearSidePanelContent() : null} + inCard={false} + /> + ); + case ImageSidePanelViews.DELETE_IMAGE: { + const bootResource = + sidePanelContent.extras && "bootResource" in sidePanelContent.extras + ? sidePanelContent.extras.bootResource + : null; + if (!bootResource) return null; + return ( + + ); + } + default: + return null; + } +}; + +export default ImagesForms; diff --git a/src/app/images/components/ImagesForms/index.ts b/src/app/images/components/ImagesForms/index.ts new file mode 100644 index 0000000000..67160cda5d --- /dev/null +++ b/src/app/images/components/ImagesForms/index.ts @@ -0,0 +1 @@ +export { default } from "./ImagesForms"; diff --git a/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.test.tsx b/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.test.tsx index 4f17d77a63..6f6e51e601 100644 --- a/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.test.tsx +++ b/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.test.tsx @@ -1,5 +1,4 @@ import { Formik } from "formik"; -import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import DeleteImageConfirm from "./DeleteImageConfirm"; @@ -12,12 +11,7 @@ import { bootResourceState as bootResourceStateFactory, rootState as rootStateFactory, } from "@/testing/factories"; -import { - userEvent, - screen, - render, - renderWithBrowserRouter, -} from "@/testing/utils"; +import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; const mockStore = configureStore(); @@ -49,12 +43,11 @@ describe("DeleteImageConfirm", () => { }), }); const store = mockStore(state); - const { unmount } = render( - - - - - + const { unmount } = renderWithBrowserRouter( + + + , + { store } ); unmount(); @@ -73,12 +66,11 @@ describe("DeleteImageConfirm", () => { }), }); const store = mockStore(state); - render( - - - - - + renderWithBrowserRouter( + + + , + { store } ); await userEvent.click( diff --git a/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx b/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx index a61611724b..58844414fd 100644 --- a/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx +++ b/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { usePrevious } from "@canonical/react-components/dist/hooks"; import { useDispatch, useSelector } from "react-redux"; -import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm"; +import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; import { actions as bootResourceActions } from "@/app/store/bootresource"; import bootResourceSelectors from "@/app/store/bootresource/selectors"; import type { BootResource } from "@/app/store/bootresource/types"; @@ -38,20 +38,23 @@ const DeleteImageConfirm = ({ }, [dispatch]); return ( - { + modelType="image" + onCancel={closeForm} + onSubmit={() => { dispatch(bootResourceActions.cleanup()); dispatch(bootResourceActions.deleteImage({ id: resource.id })); }} onSuccess={() => { dispatch(bootResourceActions.poll({ continuous: false })); + closeForm(); }} - sidebar={false} + saved={saved} + saving={saving} /> ); }; diff --git a/src/app/images/components/ImagesTable/ImagesTable.test.tsx b/src/app/images/components/ImagesTable/ImagesTable.test.tsx index 8522da2a13..372e86f430 100644 --- a/src/app/images/components/ImagesTable/ImagesTable.test.tsx +++ b/src/app/images/components/ImagesTable/ImagesTable.test.tsx @@ -3,6 +3,8 @@ import timezoneMock from "timezone-mock"; import ImagesTable, { Labels as ImagesTableLabels } from "./ImagesTable"; +import * as sidePanelHooks from "@/app/base/side-panel-context"; +import { ImageSidePanelViews } from "@/app/images/constants"; import { ConfigNames } from "@/app/store/config/types"; import type { RootState } from "@/app/store/root/types"; import { @@ -182,6 +184,13 @@ describe("ImagesTable", () => { it(`can open the delete image confirmation if the image does not use the default commissioning release`, async () => { + const setSidePanelContent = vi.fn(); + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); const resources = [ resourceFactory({ arch: "amd64", name: "ubuntu/bionic" }), ]; @@ -218,11 +227,11 @@ describe("ImagesTable", () => { await userEvent.click(delete_button); - expect( - within(row).getByRole("gridcell", { - name: ImagesTableLabels.DeleteImageConfirm, + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: ImageSidePanelViews.DELETE_IMAGE, }) - ).toBeInTheDocument(); + ); }); it(`prevents opening the delete image confirmation if the image uses the diff --git a/src/app/images/components/ImagesTable/ImagesTable.tsx b/src/app/images/components/ImagesTable/ImagesTable.tsx index cb48c0c94f..ab1d39e699 100644 --- a/src/app/images/components/ImagesTable/ImagesTable.tsx +++ b/src/app/images/components/ImagesTable/ImagesTable.tsx @@ -1,15 +1,12 @@ -import { useState } from "react"; - import { Icon, MainTable, Spinner } from "@canonical/react-components"; -import classNames from "classnames"; import { useSelector } from "react-redux"; -import DeleteImageConfirm from "./DeleteImageConfirm"; - import DoubleRow from "@/app/base/components/DoubleRow"; import TableActions from "@/app/base/components/TableActions"; import TooltipButton from "@/app/base/components/TooltipButton/TooltipButton"; -import type { ImageValue } from "@/app/images/types"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import { ImageSidePanelViews } from "@/app/images/constants"; +import type { ImageSetSidePanelContent, ImageValue } from "@/app/images/types"; import type { BootResource } from "@/app/store/bootresource/types"; import { splitResourceName } from "@/app/store/bootresource/utils"; import configSelectors from "@/app/store/config/selectors"; @@ -133,26 +130,22 @@ const generateImageRow = ( * @param resource - the resource from which to generate the row. * @param commissioningRelease - the name of the default commissioning release. * @param expanded - the resource id of the expanded row. - * @param setExpanded - function to expand the row of a resource. * @param unchecked - whether the resource checkbox is unchecked. * @returns row generated from resource. */ const generateResourceRow = ({ resource, commissioningRelease, - expanded, - setExpanded, unchecked, + setSidePanelContent, }: { resource: BootResource; commissioningRelease: string | null; - expanded: BootResource["id"] | null; - setExpanded: (id: BootResource["id"] | null) => void; unchecked: boolean; + setSidePanelContent: ImageSetSidePanelContent; }) => { const { os, release } = splitResourceName(resource.name); const canBeDeleted = !(os === "ubuntu" && release === commissioningRelease); - const isExpanded = expanded === resource.id; let statusIcon = ; let statusText = resource.status; @@ -164,9 +157,7 @@ const generateResourceRow = ({ } return { "aria-label": resource.title, - className: classNames("p-table__row", { - "is-active": isExpanded, - }), + className: "p-table__row", columns: [ { content: resource.title, className: "release-col" }, { content: resource.arch, className: "arch-col" }, @@ -211,21 +202,19 @@ const generateResourceRow = ({ data-testid="image-actions" deleteDisabled={!canBeDeleted} deleteTooltip={!canBeDeleted ? Labels.CannotDelete : null} - onDelete={() => setExpanded(resource.id)} + onDelete={() => + setSidePanelContent({ + view: ImageSidePanelViews.DELETE_IMAGE, + extras: { + bootResource: resource, + }, + }) + } /> ), className: "actions-col u-align--right", }, ], - expanded: isExpanded, - expandedContent: isExpanded ? ( -
- setExpanded(null)} - resource={resource} - /> -
- ) : null, key: `resource-${resource.id}`, sortData: { title: resource.title, @@ -246,7 +235,7 @@ const ImagesTable = ({ const commissioningRelease = useSelector( configSelectors.commissioningDistroSeries ); - const [expanded, setExpanded] = useState(null); + const { setSidePanelContent } = useSidePanel(); const isCommissioningImage = (image: ImageValue) => image.os === "ubuntu" && image.release === commissioningRelease; // Resources set for deletion are those that exist in the database, but do not @@ -263,9 +252,8 @@ const ImagesTable = ({ return generateResourceRow({ resource, commissioningRelease, - expanded, - setExpanded, unchecked: false, + setSidePanelContent: setSidePanelContent, }); } else { const commissioningImages = images.filter(isCommissioningImage); @@ -281,9 +269,8 @@ const ImagesTable = ({ generateResourceRow({ resource, commissioningRelease, - expanded, - setExpanded, unchecked: true, + setSidePanelContent: setSidePanelContent, }) ) ); diff --git a/src/app/images/constants.ts b/src/app/images/constants.ts new file mode 100644 index 0000000000..b6602dc16d --- /dev/null +++ b/src/app/images/constants.ts @@ -0,0 +1,8 @@ +export const ImageNonActionHeaderViews = { + CHANGE_SOURCE: ["imageNonActionForm", "changeSource"], + DELETE_IMAGE: ["imageNonActionForm", "deleteImage"], +} as const; + +export const ImageSidePanelViews = { + ...ImageNonActionHeaderViews, +} as const; diff --git a/src/app/images/types.ts b/src/app/images/types.ts index 16645a1ee5..74d3f02151 100644 --- a/src/app/images/types.ts +++ b/src/app/images/types.ts @@ -1,3 +1,8 @@ +import type { ValueOf } from "@canonical/react-components"; + +import type { ImageSidePanelViews } from "./constants"; + +import type { SetSidePanelContent, SidePanelContent } from "@/app/base/types"; import type { BootResource, BootResourceMeta, @@ -11,3 +16,16 @@ export type ImageValue = { subArch?: string; title: string; }; + +export type ImageSidePanelContent = + | SidePanelContent< + ValueOf, + { hasSources?: boolean } + > + | SidePanelContent< + ValueOf, + { bootResource?: BootResource } + >; + +export type ImageSetSidePanelContent = + SetSidePanelContent; diff --git a/src/app/images/views/ImageList/ImageList.tsx b/src/app/images/views/ImageList/ImageList.tsx index 9775fe88fb..27702af11c 100644 --- a/src/app/images/views/ImageList/ImageList.tsx +++ b/src/app/images/views/ImageList/ImageList.tsx @@ -10,10 +10,13 @@ import SyncedImages from "./SyncedImages"; import PageContent from "@/app/base/components/PageContent"; import { useWindowTitle } from "@/app/base/hooks"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import ImagesForms from "@/app/images/components/ImagesForms"; import { actions as bootResourceActions } from "@/app/store/bootresource"; import bootResourceSelectors from "@/app/store/bootresource/selectors"; import { actions as configActions } from "@/app/store/config"; import configSelectors from "@/app/store/config/selectors"; +import { getSidePanelTitle } from "@/app/store/utils/node/base"; export enum Labels { SyncDisabled = "Automatic image updates are disabled. This may mean that images won't be automatically updated and receive the latest package versions and security fixes.", @@ -22,6 +25,7 @@ export enum Labels { const ImageList = (): JSX.Element => { const dispatch = useDispatch(); const ubuntu = useSelector(bootResourceSelectors.ubuntu); + const { sidePanelContent, setSidePanelContent } = useSidePanel(); const autoImport = useSelector(configSelectors.bootImagesAutoImport); const configLoaded = useSelector(configSelectors.loaded); useWindowTitle("Images"); @@ -37,8 +41,15 @@ const ImageList = (): JSX.Element => { return ( } - sidePanelContent={null} - sidePanelTitle={null} + sidePanelContent={ + sidePanelContent && ( + + ) + } + sidePanelTitle={getSidePanelTitle("Images", sidePanelContent)} > {configLoaded && ( <> diff --git a/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesForm.tsx b/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesForm.tsx index c15baf82a4..03e460feed 100644 --- a/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesForm.tsx +++ b/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesForm.tsx @@ -54,6 +54,7 @@ const FetchImagesForm = ({ closeForm, setSource }: Props): JSX.Element => { return ( allowUnchanged + aria-label="Choose source" cleanup={cleanup} errors={errors as APIError} initialValues={{ diff --git a/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesFormFields/FetchImagesFormFields.tsx b/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesFormFields/FetchImagesFormFields.tsx index 07b6d75398..b399b933d6 100644 --- a/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesFormFields/FetchImagesFormFields.tsx +++ b/src/app/images/views/ImageList/SyncedImages/ChangeSource/FetchImagesForm/FetchImagesFormFields/FetchImagesFormFields.tsx @@ -28,7 +28,6 @@ const FetchImagesFormFields = (): JSX.Element => { return ( -

{Labels.ChooseSource}

  • { - it("can render the form in a card", async () => { + const setSidePanelContent = vi.fn(); + + beforeEach(() => { + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); + }); + + it("can trigger a side panel form", async () => { const state = rootStateFactory({ bootresource: bootResourceStateFactory({ ubuntu: ubuntuFactory({ @@ -26,14 +39,18 @@ describe("SyncedImages", () => { }), }), }); - renderWithBrowserRouter(, { + renderWithBrowserRouter(, { state, }); await userEvent.click( screen.getByRole("button", { name: SyncedImagesLabels.ChangeSource }) ); - expect(screen.getByText("Choose source")).toBeInTheDocument(); + + expect(setSidePanelContent).toHaveBeenCalledWith({ + view: ImageSidePanelViews.CHANGE_SOURCE, + extras: { hasSources: true }, + }); }); it("renders the change source form and disables closing it if no sources are detected", () => { @@ -43,10 +60,11 @@ describe("SyncedImages", () => { }), }); renderWithBrowserRouter(, { state }); - expect(screen.getByText("Choose source")).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Cancel" }) - ).not.toBeInTheDocument(); + + expect(setSidePanelContent).toHaveBeenCalledWith({ + view: ImageSidePanelViews.CHANGE_SOURCE, + extras: { hasSources: false }, + }); }); it("renders the correct text for a single default source", () => { diff --git a/src/app/images/views/ImageList/SyncedImages/SyncedImages.tsx b/src/app/images/views/ImageList/SyncedImages/SyncedImages.tsx index e2560e3213..3c8e63e343 100644 --- a/src/app/images/views/ImageList/SyncedImages/SyncedImages.tsx +++ b/src/app/images/views/ImageList/SyncedImages/SyncedImages.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useEffect } from "react"; -import type { PropsWithSpread, StripProps } from "@canonical/react-components"; +import type { StripProps } from "@canonical/react-components"; import { Button, Col, @@ -11,11 +11,12 @@ import { } from "@canonical/react-components"; import { useSelector } from "react-redux"; -import ChangeSource from "./ChangeSource"; import OtherImages from "./OtherImages"; import UbuntuCoreImages from "./UbuntuCoreImages"; import UbuntuImages from "./UbuntuImages"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import { ImageSidePanelViews } from "@/app/images/constants"; import bootResourceSelectors from "@/app/store/bootresource/selectors"; import type { BootResourceUbuntuSource } from "@/app/store/bootresource/types"; import { BootResourceSourceType } from "@/app/store/bootresource/types"; @@ -36,22 +37,23 @@ export enum Labels { SyncedFrom = "Showing images synced from", } -type Props = PropsWithSpread< - { - formInCard?: boolean; - }, - Partial ->; +type Props = Partial; -const SyncedImages = ({ - formInCard = true, - ...stripProps -}: Props): JSX.Element | null => { +const SyncedImages = ({ ...stripProps }: Props): JSX.Element | null => { const ubuntu = useSelector(bootResourceSelectors.ubuntu); const resources = useSelector(bootResourceSelectors.resources); + const { setSidePanelContent } = useSidePanel(); const sources = ubuntu?.sources || []; const hasSources = sources.length !== 0; - const [showChangeSource, setShowChangeSource] = useState(!hasSources); + + useEffect(() => { + if (!hasSources) { + setSidePanelContent({ + view: ImageSidePanelViews.CHANGE_SOURCE, + extras: { hasSources }, + }); + } + }, [hasSources, setSidePanelContent]); if (!ubuntu) { return null; @@ -62,44 +64,41 @@ const SyncedImages = ({ - {showChangeSource ? ( - setShowChangeSource(false) : null} - inCard={formInCard} - /> - ) : ( - <> -
    -

    - {Labels.SyncedFrom}{" "} - {getImageSyncText(sources)} -

    - -
    -

    - Select images to be imported and kept in sync daily. Images will - be available for deploying to machines managed by MAAS. -

    - - - - - )} + <> +
    +

    + {Labels.SyncedFrom} {getImageSyncText(sources)} +

    + +
    +

    + Select images to be imported and kept in sync daily. Images will + be available for deploying to machines managed by MAAS. +

    + + + +
    diff --git a/src/app/intro/views/ImagesIntro/ImagesIntro.tsx b/src/app/intro/views/ImagesIntro/ImagesIntro.tsx index 7859f42536..60919d6553 100644 --- a/src/app/intro/views/ImagesIntro/ImagesIntro.tsx +++ b/src/app/intro/views/ImagesIntro/ImagesIntro.tsx @@ -37,7 +37,7 @@ const ImagesIntro = (): JSX.Element => { return ( - +
    + + + ); + return ( - - - {machineCount} machines - in {resourcePoolsCount} {pluralize("pool", resourcePoolsCount)} - - - - - - } - sidePanelContent={null} - sidePanelTitle={null} - > - - } - path={getRelativeRoute(urls.pools.index, base)} - /> - } - path={getRelativeRoute(urls.pools.add, base)} - /> - } - path={getRelativeRoute(urls.pools.edit(null), base)} - /> - } path="*" /> - - + + } + sidePanelContent={null} + sidePanelTitle={null} + > + + + } + path={getRelativeRoute(urls.pools.index, base)} + /> + } + sidePanelContent={} + sidePanelTitle="Add pool" + > + + + } + path={getRelativeRoute(urls.pools.add, base)} + /> + } + sidePanelContent={} + sidePanelTitle="Edit pool" + > + + + } + path={getRelativeRoute(urls.pools.edit(null), base)} + /> + } + sidePanelContent={} + sidePanelTitle="Delete pool" + > + + + } + path={getRelativeRoute(urls.pools.delete(null), base)} + /> + } + sidePanelContent={null} + sidePanelTitle={null} + > + + + } + path="*" + /> + ); }; diff --git a/src/app/preferences/components/Routes/Routes.tsx b/src/app/preferences/components/Routes/Routes.tsx index 543f831aba..5aa4523990 100644 --- a/src/app/preferences/components/Routes/Routes.tsx +++ b/src/app/preferences/components/Routes/Routes.tsx @@ -1,12 +1,15 @@ import { Redirect } from "react-router"; import { Route, Routes as ReactRouterRoutes } from "react-router-dom-v5-compat"; +import PageContent from "@/app/base/components/PageContent"; import urls from "@/app/base/urls"; import NotFound from "@/app/base/views/NotFound"; import APIKeyAdd from "@/app/preferences/views/APIKeys/APIKeyAdd"; +import APIKeyDelete from "@/app/preferences/views/APIKeys/APIKeyDelete"; import APIKeyEdit from "@/app/preferences/views/APIKeys/APIKeyEdit"; import APIKeyList from "@/app/preferences/views/APIKeys/APIKeyList"; import Details from "@/app/preferences/views/Details"; +import { Labels as PreferenceLabels } from "@/app/preferences/views/Preferences"; import AddSSHKey from "@/app/preferences/views/SSHKeys/AddSSHKey"; import SSHKeyList from "@/app/preferences/views/SSHKeys/SSHKeyList"; import AddSSLKey from "@/app/preferences/views/SSLKeys/AddSSLKey"; @@ -19,35 +22,94 @@ const Routes = (): JSX.Element => { } path="/" /> } + element={ + +
    + + } path={getRelativeRoute(urls.preferences.details, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.preferences.apiKeys.index, base)} /> } + element={ + } + sidePanelTitle="Generate MAAS API key" + > + + + } path={getRelativeRoute(urls.preferences.apiKeys.add, base)} /> } + element={ + } + sidePanelTitle="Edit MAAS API key" + > + + + } path={getRelativeRoute(urls.preferences.apiKeys.edit(null), base)} /> } + element={ + } + sidePanelTitle="Delete MAAS API key" + > + + + } + path={getRelativeRoute(urls.preferences.apiKeys.delete(null), base)} + /> + + + + } path={getRelativeRoute(urls.preferences.sshKeys.index, base)} /> } + element={ + } + sidePanelTitle="Add SSH key" + > + + + } path={getRelativeRoute(urls.preferences.sshKeys.add, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.preferences.sslKeys.index, base)} /> } + element={ + } + sidePanelTitle="Add SSL key" + > + + + } path={getRelativeRoute(urls.preferences.sslKeys.add, base)} /> } path="*" /> diff --git a/src/app/preferences/urls.ts b/src/app/preferences/urls.ts index 633caf843d..ca8d9d629f 100644 --- a/src/app/preferences/urls.ts +++ b/src/app/preferences/urls.ts @@ -5,6 +5,7 @@ const urls = { apiKeys: { add: "/account/prefs/api-keys/add", edit: argPath<{ id: Token["id"] }>("/account/prefs/api-keys/:id/edit"), + delete: argPath<{ id: Token["id"] }>("/account/prefs/api-keys/:id/delete"), index: "/account/prefs/api-keys", }, details: "/account/prefs/details", diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.test.tsx b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.test.tsx new file mode 100644 index 0000000000..c9b4642a0f --- /dev/null +++ b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.test.tsx @@ -0,0 +1,38 @@ +import APIKeyDelete from "./APIKeyDelete"; + +import type { RootState } from "@/app/store/root/types"; +import { + token as tokenFactory, + tokenState as tokenStateFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen } from "@/testing/utils"; + +let state: RootState; +rootStateFactory({ + token: tokenStateFactory({ + items: [ + tokenFactory({ + id: 1, + key: "ssh-rsa aabb", + consumer: { key: "abc", name: "Name" }, + }), + tokenFactory({ + id: 2, + key: "ssh-rsa ccdd", + consumer: { key: "abc", name: "Name" }, + }), + ], + }), +}); + +it("renders", () => { + renderWithBrowserRouter(, { + state, + route: "/account/prefs/api-keys/1/delete", + routePattern: "/account/prefs/api-keys/:id/delete", + }); + expect( + screen.getByRole("form", { name: "Delete API Key" }) + ).toBeInTheDocument(); +}); diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx new file mode 100644 index 0000000000..3d2c0f7615 --- /dev/null +++ b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx @@ -0,0 +1,21 @@ +import { useOnEscapePressed } from "@canonical/react-components"; +import { useNavigate } from "react-router-dom-v5-compat"; + +import { useGetURLId } from "@/app/base/hooks"; +import urls from "@/app/base/urls"; +import APIKeyDeleteForm from "@/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm"; + +const APIKeyDelete = () => { + const id = useGetURLId("id"); + const navigate = useNavigate(); + const onCancel = () => navigate({ pathname: urls.preferences.apiKeys.index }); + useOnEscapePressed(() => onCancel()); + + if (!id) { + return

    API Key not found

    ; + } + + return ; +}; + +export default APIKeyDelete; diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/index.ts b/src/app/preferences/views/APIKeys/APIKeyDelete/index.ts new file mode 100644 index 0000000000..4b3fdab73e --- /dev/null +++ b/src/app/preferences/views/APIKeys/APIKeyDelete/index.ts @@ -0,0 +1 @@ +export { default } from "./APIKeyDelete"; diff --git a/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx new file mode 100644 index 0000000000..305168877c --- /dev/null +++ b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx @@ -0,0 +1,33 @@ +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom-v5-compat"; + +import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; +import urls from "@/app/base/urls"; +import { actions as tokenActions } from "@/app/store/token"; +import tokenSelectors from "@/app/store/token/selectors"; + +const APIKeyDeleteForm = ({ id }: { id: number }) => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const saved = useSelector(tokenSelectors.saved); + const saving = useSelector(tokenSelectors.saving); + + return ( + navigate({ pathname: urls.preferences.apiKeys.index })} + onSubmit={() => { + dispatch(tokenActions.delete(id)); + }} + saved={saved} + savedRedirect={urls.preferences.apiKeys.index} + saving={saving} + submitAppearance="negative" + submitLabel="Delete" + /> + ); +}; + +export default APIKeyDeleteForm; diff --git a/src/app/preferences/views/APIKeys/APIKeyDeleteForm/index.ts b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/index.ts new file mode 100644 index 0000000000..fa3274b35f --- /dev/null +++ b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/index.ts @@ -0,0 +1 @@ +export { default } from "./APIKeyDeleteForm"; diff --git a/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx b/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx index b1c6263425..31e4487999 100644 --- a/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx +++ b/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx @@ -1,12 +1,11 @@ -import { Col, Row } from "@canonical/react-components"; +import { Col, Row, useOnEscapePressed } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom-v5-compat"; import * as Yup from "yup"; -import FormCard from "@/app/base/components/FormCard"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; -import { useAddMessage, useWindowTitle } from "@/app/base/hooks"; +import { useAddMessage } from "@/app/base/hooks"; import urls from "@/app/base/urls"; import { actions as tokenActions } from "@/app/store/token"; import tokenSelectors from "@/app/store/token/selectors"; @@ -42,9 +41,9 @@ export const APIKeyForm = ({ token }: Props): JSX.Element => { const errors = useSelector(tokenSelectors.errors); const saved = useSelector(tokenSelectors.saved); const saving = useSelector(tokenSelectors.saving); - const title = editing ? Label.EditTitle : Label.AddTitle; + const onCancel = () => navigate({ pathname: urls.preferences.apiKeys.index }); + useOnEscapePressed(() => onCancel()); - useWindowTitle(title); useAddMessage( saved, tokenActions.cleanup, @@ -52,59 +51,57 @@ export const APIKeyForm = ({ token }: Props): JSX.Element => { ); return ( - - navigate({ pathname: urls.preferences.apiKeys.index })} - onSaveAnalytics={{ - action: "Saved", - category: "API keys preferences", - label: "Generate API key form", - }} - onSubmit={(values) => { - if (editing) { - if (token) { - dispatch( - tokenActions.update({ - id: token.id, - name: values.name, - }) - ); - } - } else { - dispatch(tokenActions.create(values)); + { + if (editing) { + if (token) { + dispatch( + tokenActions.update({ + id: token.id, + name: values.name, + }) + ); } - }} - saved={saved} - savedRedirect={urls.preferences.apiKeys.index} - saving={saving} - submitLabel={editing ? Label.EditSubmit : Label.AddSubmit} - validationSchema={editing ? APIKeyEditSchema : APIKeyAddSchema} - > - - - - - -

    - The API key is used to log in to the API from the MAAS CLI and by - other services connecting to MAAS, such as Juju. -

    - -
    -
    -
    + } else { + dispatch(tokenActions.create(values)); + } + }} + saved={saved} + savedRedirect={urls.preferences.apiKeys.index} + saving={saving} + submitLabel={editing ? Label.EditSubmit : Label.AddSubmit} + validationSchema={editing ? APIKeyEditSchema : APIKeyAddSchema} + > + + + + + +

    + The API key is used to log in to the API from the MAAS CLI and by + other services connecting to MAAS, such as Juju. +

    + +
    + ); }; diff --git a/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx b/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx index 65f4c6aa42..59d2606b87 100644 --- a/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx +++ b/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx @@ -1,11 +1,7 @@ -import { useState } from "react"; - import { Notification } from "@canonical/react-components"; -import { useDispatch, useSelector } from "react-redux"; -import type { Dispatch } from "redux"; +import { useSelector } from "react-redux"; import TableActions from "@/app/base/components/TableActions"; -import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm"; import { useFetchActions, useAddMessage, @@ -15,28 +11,18 @@ import urls from "@/app/base/urls"; import SettingsTable from "@/app/settings/components/SettingsTable"; import { actions as tokenActions } from "@/app/store/token"; import tokenSelectors from "@/app/store/token/selectors"; -import type { Token, TokenMeta, TokenState } from "@/app/store/token/types"; +import type { Token } from "@/app/store/token/types"; export enum Label { Title = "API keys", EmptyList = "No API keys available.", } -const generateRows = ( - tokens: Token[], - expandedId: Token[TokenMeta.PK] | null, - setExpandedId: (id: Token[TokenMeta.PK] | null) => void, - hideExpanded: () => void, - dispatch: Dispatch, - saved: TokenState["saved"], - saving: TokenState["saving"] -) => +const generateRows = (tokens: Token[]) => tokens.map(({ consumer, id, key, secret }) => { const { name } = consumer; - const expanded = expandedId === id; const token = `${consumer.key}:${key}:${secret}`; return { - className: expanded ? "p-table__row is-active" : null, columns: [ { content: name, @@ -49,26 +35,13 @@ const generateRows = ( content: ( setExpandedId(id)} /> ), className: "u-align--right", }, ], - expanded: expanded, - expandedContent: expanded && ( - { - dispatch(tokenActions.delete(id)); - }} - /> - ), key: id, sortData: { name: name, @@ -77,20 +50,11 @@ const generateRows = ( }); const APIKeyList = (): JSX.Element => { - const [expandedId, setExpandedId] = useState( - null - ); const errors = useSelector(tokenSelectors.errors); const loading = useSelector(tokenSelectors.loading); const loaded = useSelector(tokenSelectors.loaded); const tokens = useSelector(tokenSelectors.all); const saved = useSelector(tokenSelectors.saved); - const saving = useSelector(tokenSelectors.saving); - const dispatch = useDispatch(); - - const hideExpanded = () => { - setExpandedId(null); - }; useAddMessage(saved, tokenActions.cleanup, "API key deleted successfully."); @@ -129,15 +93,7 @@ const APIKeyList = (): JSX.Element => { ]} loaded={loaded} loading={loading} - rows={generateRows( - tokens, - expandedId, - setExpandedId, - hideExpanded, - dispatch, - saved, - saving - )} + rows={generateRows(tokens)} tableClassName="apikey-list" /> diff --git a/src/app/preferences/views/Preferences.test.tsx b/src/app/preferences/views/Preferences.test.tsx deleted file mode 100644 index aafe1d0295..0000000000 --- a/src/app/preferences/views/Preferences.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Preferences, { Labels as PreferencesLabels } from "./Preferences"; - -import { screen, renderWithBrowserRouter, getTestState } from "@/testing/utils"; - -describe("Preferences", () => { - it("renders", () => { - const state = getTestState(); - renderWithBrowserRouter(, { route: "/preferences", state }); - - expect(screen.getByLabelText(PreferencesLabels.Title)).toBeInTheDocument(); - }); -}); diff --git a/src/app/preferences/views/Preferences.tsx b/src/app/preferences/views/Preferences.tsx index 19318af1bf..764ccc606b 100644 --- a/src/app/preferences/views/Preferences.tsx +++ b/src/app/preferences/views/Preferences.tsx @@ -1,18 +1,9 @@ -import PageContent from "@/app/base/components/PageContent/PageContent"; import Routes from "@/app/preferences/components/Routes"; export enum Labels { Title = "My preferences", } -const Preferences = (): JSX.Element => ( - - - -); +const Preferences = (): JSX.Element => ; export default Preferences; diff --git a/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx b/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx index 617ac0f7c7..b5b489b3e5 100644 --- a/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx +++ b/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx @@ -1,9 +1,7 @@ +import { useOnEscapePressed } from "@canonical/react-components"; import { useNavigate } from "react-router-dom-v5-compat"; -import FormCard from "@/app/base/components/FormCard"; import SSHKeyForm from "@/app/base/components/SSHKeyForm"; -import { COL_SIZES } from "@/app/base/constants"; -import { useWindowTitle } from "@/app/base/hooks"; import urls from "@/app/base/urls"; export enum Label { @@ -11,26 +9,23 @@ export enum Label { FormLabel = "Add SSH key form", } -const { CARD_TITLE, SIDEBAR, TOTAL } = COL_SIZES; - export const AddSSHKey = (): JSX.Element => { const navigate = useNavigate(); - useWindowTitle(Label.Title); + const onCancel = () => navigate({ pathname: urls.preferences.sshKeys.index }); + useOnEscapePressed(() => onCancel()); return ( - - navigate({ pathname: urls.preferences.sshKeys.index })} - onSaveAnalytics={{ - action: "Saved", - category: "SSH keys preferences", - label: "Import SSH key form", - }} - savedRedirect={urls.preferences.sshKeys.index} - /> - + ); }; diff --git a/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx b/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx index 9874b6a2fa..40adc27d8a 100644 --- a/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx +++ b/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx @@ -1,13 +1,17 @@ -import { Col, Row, Textarea } from "@canonical/react-components"; +import { + Col, + Row, + Textarea, + useOnEscapePressed, +} from "@canonical/react-components"; import type { TextareaProps } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom-v5-compat"; import * as Yup from "yup"; -import FormCard from "@/app/base/components/FormCard"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; -import { useAddMessage, useWindowTitle } from "@/app/base/hooks"; +import { useAddMessage } from "@/app/base/hooks"; import urls from "@/app/base/urls"; import { actions as sslkeyActions } from "@/app/store/sslkey"; import sslkeySelectors from "@/app/store/sslkey/selectors"; @@ -35,55 +39,53 @@ export const AddSSLKey = (): JSX.Element => { const saving = useSelector(sslkeySelectors.saving); const saved = useSelector(sslkeySelectors.saved); const errors = useSelector(sslkeySelectors.errors); - - useWindowTitle(Label.Title); + const onCancel = () => navigate({ pathname: urls.preferences.sslKeys.index }); + useOnEscapePressed(() => onCancel()); useAddMessage(saved, sslkeyActions.cleanup, "SSL key successfully added."); return ( - - navigate({ pathname: urls.preferences.sslKeys.index })} - onSaveAnalytics={{ - action: "Saved", - category: "SSL keys preferences", - label: "Add SSL key form", - }} - onSubmit={(values) => { - dispatch(sslkeyActions.create(values)); - }} - saved={saved} - savedRedirect={urls.preferences.sslKeys.index} - saving={saving} - submitLabel={Label.SubmitLabel} - validationSchema={SSLKeySchema} - > - - - - - -

    - You will be able to access Windows winrm service with a registered - key. -

    - -
    -
    -
    + { + dispatch(sslkeyActions.create(values)); + }} + saved={saved} + savedRedirect={urls.preferences.sslKeys.index} + saving={saving} + submitLabel={Label.SubmitLabel} + validationSchema={SSLKeySchema} + > + + + + + +

    + You will be able to access Windows winrm service with a registered + key. +

    + +
    +
    ); }; diff --git a/src/app/settings/components/Routes/Routes.test.tsx b/src/app/settings/components/Routes/Routes.test.tsx index 8d373109ed..7015605571 100644 --- a/src/app/settings/components/Routes/Routes.test.tsx +++ b/src/app/settings/components/Routes/Routes.test.tsx @@ -89,29 +89,10 @@ const routes = [ title: "Users", path: urls.settings.users.index, }, - { - title: "Add user", - path: urls.settings.users.add, - }, - { - title: `Editing \`${user.username}\``, - path: urls.settings.users.edit({ id: user.id }), - }, { title: "License keys", path: urls.settings.licenseKeys.index, }, - { - title: "Add license key", - path: urls.settings.licenseKeys.add, - }, - { - title: "Update license key", - path: urls.settings.licenseKeys.edit({ - osystem: licensekey.osystem, - distro_series: licensekey.distro_series, - }), - }, { title: "Storage", path: urls.settings.storage, @@ -140,45 +121,18 @@ const routes = [ title: "Commissioning scripts", path: urls.settings.scripts.commissioning.index, }, - { - title: "Upload commissioning script", - path: urls.settings.scripts.commissioning.upload, - }, { title: "Testing scripts", path: urls.settings.scripts.testing.index, }, - { - title: "Upload testing script", - path: urls.settings.scripts.testing.upload, - }, { title: "DHCP snippets", path: urls.settings.dhcp.index, }, - { - title: "Add DHCP snippet", - path: urls.settings.dhcp.add, - }, - { - title: `Editing \`${dhcpSnippet.name}\``, - path: urls.settings.dhcp.edit({ id: dhcpSnippet.id }), - }, { title: "Package repos", path: urls.settings.repositories.index, }, - { - title: "Add PPA", - path: urls.settings.repositories.add({ type: "ppa" }), - }, - { - title: "Edit PPA", - path: urls.settings.repositories.edit({ - id: packageRepository.id, - type: "ppa", - }), - }, { title: "Windows", path: urls.settings.images.windows, diff --git a/src/app/settings/components/Routes/Routes.tsx b/src/app/settings/components/Routes/Routes.tsx index b5b36541a5..19fa409e5a 100644 --- a/src/app/settings/components/Routes/Routes.tsx +++ b/src/app/settings/components/Routes/Routes.tsx @@ -1,6 +1,7 @@ import { Redirect } from "react-router-dom"; import { Route, Routes as ReactRouterRoutes } from "react-router-dom-v5-compat"; +import PageContent from "@/app/base/components/PageContent"; import urls from "@/app/base/urls"; import NotFound from "@/app/base/views/NotFound"; import Commissioning from "@/app/settings/views/Configuration/Commissioning"; @@ -32,6 +33,7 @@ import SecurityProtocols from "@/app/settings/views/Security/SecurityProtocols"; import SessionTimeout from "@/app/settings/views/Security/SessionTimeout"; import StorageForm from "@/app/settings/views/Storage/StorageForm"; import UserAdd from "@/app/settings/views/Users/UserAdd"; +import UserDelete from "@/app/settings/views/Users/UserDelete"; import UserEdit from "@/app/settings/views/Users/UserEdit"; import UsersList from "@/app/settings/views/Users/UsersList"; import { getRelativeRoute } from "@/app/utils"; @@ -41,22 +43,38 @@ const Routes = (): JSX.Element => { return ( } + element={ + + + + } path={getRelativeRoute(urls.settings.configuration.general, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.configuration.commissioning, base)} /> } + element={ + + + + } path={getRelativeRoute( urls.settings.configuration.kernelParameters, base )} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.configuration.deploy, base)} /> { path={getRelativeRoute(urls.settings.configuration.index, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.security.securityProtocols, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.security.secretStorage, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.security.sessionTimeout, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.security.ipmiSettings, base)} /> { path={getRelativeRoute(urls.settings.security.index, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.users.index, base)} /> } + element={ + } sidePanelTitle="Add User"> + + + } path={getRelativeRoute(urls.settings.users.add, base)} /> } + element={ + } + sidePanelTitle="Edit User" + > + + + } path={getRelativeRoute(urls.settings.users.edit(null), base)} /> } + element={ + } + sidePanelTitle="Delete User" + > + + + } + path={getRelativeRoute(urls.settings.users.delete(null), base)} + /> + + + + } path={getRelativeRoute(urls.settings.licenseKeys.index, base)} /> } + element={ + } + sidePanelTitle="Add license key" + > + + + } path={getRelativeRoute(urls.settings.licenseKeys.add, base)} /> } + element={ + } + sidePanelTitle="Update license key" + > + + + } path={getRelativeRoute(urls.settings.licenseKeys.edit(null), base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.storage, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.network.proxy, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.network.dns, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.network.ntp, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.network.syslog, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.network.networkDiscovery, base)} /> { path={getRelativeRoute(urls.settings.network.index, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.scripts.commissioning.index, base)} /> } + element={ + } + sidePanelTitle="Upload commissioning script" + > + + + } path={getRelativeRoute( urls.settings.scripts.commissioning.upload, base )} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.scripts.testing.index, base)} /> } + element={ + } + sidePanelTitle="Upload testing script" + > + + + } path={getRelativeRoute(urls.settings.scripts.testing.upload, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.dhcp.index, base)} /> } + element={ + } + sidePanelTitle="Add DHCP snippet" + > + + + } path={getRelativeRoute(urls.settings.dhcp.add, base)} /> } + element={ + } + sidePanelTitle="Edit DHCP snippet" + > + + + } path={getRelativeRoute(urls.settings.dhcp.edit(null), base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.repositories.index, base)} /> } + element={ + } + sidePanelTitle="Add Repository" + > + + + } path={getRelativeRoute(urls.settings.repositories.add(null), base)} /> } + element={ + } + sidePanelTitle={"Edit Repository"} + > + + + } path={getRelativeRoute(urls.settings.repositories.edit(null), base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.images.windows, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.images.vmware, base)} /> } + element={ + + + + } path={getRelativeRoute(urls.settings.images.ubuntu, base)} /> - } path="*" /> + + + + } + path="*" + /> ); }; diff --git a/src/app/settings/urls.ts b/src/app/settings/urls.ts index 6d4253fb76..8f58ebe505 100644 --- a/src/app/settings/urls.ts +++ b/src/app/settings/urls.ts @@ -69,6 +69,7 @@ const urls = { users: { add: "/settings/users/add", edit: argPath<{ id: User["id"] }>("/settings/users/:id/edit"), + delete: argPath<{ id: User["id"] }>("/settings/users/:id/delete"), index: "/settings/users", }, } as const; diff --git a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx index 4af3de4bfd..0975132717 100644 --- a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx +++ b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx @@ -80,7 +80,7 @@ describe("DhcpForm", () => { ); expect( - screen.getByRole("heading", { name: "Editing `lease`" }) + screen.getByRole("form", { name: "Editing `lease`" }) ).toBeInTheDocument(); }); }); diff --git a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx index a28e158b00..f2f95e602d 100644 --- a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx +++ b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx @@ -4,8 +4,6 @@ import { useNavigate } from "react-router-dom-v5-compat"; import BaseDhcpForm from "@/app/base/components/DhcpForm"; import type { DHCPFormValues } from "@/app/base/components/DhcpForm/types"; -import FormCard from "@/app/base/components/FormCard"; -import { useWindowTitle } from "@/app/base/hooks"; import settingsURLs from "@/app/settings/urls"; import type { DHCPSnippet } from "@/app/store/dhcpsnippet/types"; @@ -19,21 +17,17 @@ export const DhcpForm = ({ dhcpSnippet }: Props): JSX.Element => { const editing = !!dhcpSnippet; const title = editing ? `Editing \`${name}\`` : "Add DHCP snippet"; - useWindowTitle(title); - return ( - - navigate({ pathname: settingsURLs.dhcp.index })} - onValuesChanged={(values) => { - setName(values.name); - }} - savedRedirect={settingsURLs.dhcp.index} - /> - + navigate({ pathname: settingsURLs.dhcp.index })} + onValuesChanged={(values) => { + setName(values.name); + }} + savedRedirect={settingsURLs.dhcp.index} + /> ); }; diff --git a/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx b/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx index fd2a8028e9..ea0ce46cb7 100644 --- a/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx +++ b/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx @@ -9,9 +9,8 @@ import LicenseKeyFormFields from "../LicenseKeyFormFields"; import type { LicenseKeyFormValues } from "./types"; -import FormCard from "@/app/base/components/FormCard"; import FormikForm from "@/app/base/components/FormikForm"; -import { useAddMessage, useWindowTitle } from "@/app/base/hooks"; +import { useAddMessage } from "@/app/base/hooks"; import settingsURLs from "@/app/settings/urls"; import { actions as generalActions } from "@/app/store/general"; import { osInfo as osInfoSelectors } from "@/app/store/general/selectors"; @@ -50,8 +49,6 @@ export const LicenseKeyForm = ({ licenseKey }: Props): JSX.Element => { const title = licenseKey ? "Update license key" : "Add license key"; - useWindowTitle(title); - const editing = !!licenseKey; useAddMessage( @@ -71,7 +68,7 @@ export const LicenseKeyForm = ({ licenseKey }: Props): JSX.Element => { }, [dispatch, osInfoLoaded, licenseKeysLoaded]); return ( - + <> {!isLoaded ? ( ) : osystems.length > 0 ? ( @@ -125,7 +122,7 @@ export const LicenseKeyForm = ({ licenseKey }: Props): JSX.Element => { ) : ( No available licensed operating systems. )} - + ); }; diff --git a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx index 069e995bac..c0988b4287 100644 --- a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx +++ b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx @@ -120,7 +120,9 @@ describe("RepositoryForm", () => { ); - expect(screen.getByText("Add repository")).toBeInTheDocument(); + expect( + screen.getByRole("form", { name: "Add repository" }) + ).toBeInTheDocument(); rerender( @@ -133,7 +135,7 @@ describe("RepositoryForm", () => { ); - expect(screen.getByText("Add PPA")).toBeInTheDocument(); + expect(screen.getByRole("form", { name: "Add PPA" })).toBeInTheDocument(); rerender( @@ -149,7 +151,9 @@ describe("RepositoryForm", () => { ); - expect(screen.getByText("Edit repository")).toBeInTheDocument(); + expect( + screen.getByRole("form", { name: "Edit repository" }) + ).toBeInTheDocument(); rerender( @@ -165,7 +169,7 @@ describe("RepositoryForm", () => { ); - expect(screen.getByText("Edit PPA")).toBeInTheDocument(); + expect(screen.getByRole("form", { name: "Edit PPA" })).toBeInTheDocument(); }); it("cleans up when unmounting", async () => { diff --git a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx index c6ae6a8b20..c88dc2913e 100644 --- a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx +++ b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx @@ -9,9 +9,8 @@ import RepositoryFormFields from "../RepositoryFormFields"; import type { RepositoryFormValues } from "./types"; -import FormCard from "@/app/base/components/FormCard"; import FormikForm from "@/app/base/components/FormikForm"; -import { useAddMessage, useWindowTitle } from "@/app/base/hooks"; +import { useAddMessage } from "@/app/base/hooks"; import settingsURLs from "@/app/settings/urls"; import { actions as generalActions } from "@/app/store/general"; import { @@ -119,14 +118,12 @@ export const RepositoryForm = ({ type, repository }: Props): JSX.Element => { }; } - useWindowTitle(title); - return ( <> {!allLoaded ? ( ) : ( - + <> aria-label={title} cleanup={repositoryActions.cleanup} @@ -183,7 +180,7 @@ export const RepositoryForm = ({ type, repository }: Props): JSX.Element => { > - + )} ); diff --git a/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx b/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx index ecf3743078..560c7975da 100644 --- a/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx +++ b/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx @@ -75,7 +75,7 @@ const RepositoryFormFields = ({ type }: Props): JSX.Element => { return ( - + { )} - + { const [script, setScript] = useState(null); const dispatch = useDispatch(); const navigate = useNavigate(); - const title = `Upload ${type} script`; const listLocation = `/settings/scripts/${type}`; - useWindowTitle(title); - useEffect(() => { if (hasErrors && errors && typeof errors === "object") { Object.values(errors).forEach((error) => { @@ -127,7 +122,7 @@ const ScriptsUpload = ({ type }: Props): JSX.Element => { const uploadedFile: FileWithPath = acceptedFiles[0]; return ( - +
    { ) : null} - +
    ); }; diff --git a/src/app/settings/views/Settings.tsx b/src/app/settings/views/Settings.tsx index 99ef84e755..d8df0d5fb1 100644 --- a/src/app/settings/views/Settings.tsx +++ b/src/app/settings/views/Settings.tsx @@ -24,11 +24,7 @@ const Settings = (): JSX.Element => { ); } - return ( - - - - ); + return ; }; export default Settings; diff --git a/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx b/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx index ef51fc8ae7..d4563786ff 100644 --- a/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx +++ b/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx @@ -32,8 +32,6 @@ describe("UserAdd", () => { , { state } ); - expect( - screen.getByRole("heading", { name: "Add user" }) - ).toBeInTheDocument(); + expect(screen.getByRole("form", { name: "Add user" })).toBeInTheDocument(); }); }); diff --git a/src/app/settings/views/Users/UserDelete/UserDelete.test.tsx b/src/app/settings/views/Users/UserDelete/UserDelete.test.tsx new file mode 100644 index 0000000000..be1b48b9ad --- /dev/null +++ b/src/app/settings/views/Users/UserDelete/UserDelete.test.tsx @@ -0,0 +1,43 @@ +import UserDelete from "./UserDelete"; + +import type { RootState } from "@/app/store/root/types"; +import { + rootState as rootStateFactory, + statusState as statusStateFactory, + user as userFactory, + userState as userStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen } from "@/testing/utils"; + +let state: RootState; + +beforeEach(() => { + state = rootStateFactory({ + status: statusStateFactory({ + externalAuthURL: null, + }), + user: userStateFactory({ + loaded: true, + items: [ + userFactory({ + email: "admin@example.com", + global_permissions: ["machine_create"], + id: 1, + is_superuser: true, + last_name: "", + sshkeys_count: 0, + username: "admin", + }), + ], + }), + }); +}); + +it("renders", () => { + renderWithBrowserRouter(, { + state, + route: "/settings/users/1/edit", + routePattern: "/settings/users/:id/edit", + }); + expect(screen.getByRole("form", { name: "Delete user" })); +}); diff --git a/src/app/settings/views/Users/UserDelete/UserDelete.tsx b/src/app/settings/views/Users/UserDelete/UserDelete.tsx new file mode 100644 index 0000000000..e7471bc73f --- /dev/null +++ b/src/app/settings/views/Users/UserDelete/UserDelete.tsx @@ -0,0 +1,22 @@ +import { useSelector } from "react-redux"; + +import { useGetURLId } from "@/app/base/hooks"; +import UserDeleteForm from "@/app/settings/views/Users/UserDeleteForm"; +import type { RootState } from "@/app/store/root/types"; +import userSelectors from "@/app/store/user/selectors"; +import { UserMeta } from "@/app/store/user/types"; + +const UserDelete = () => { + const id = useGetURLId(UserMeta.PK); + const user = useSelector((state: RootState) => + userSelectors.getById(state, id) + ); + + if (!user) { + return

    User not found

    ; + } + + return ; +}; + +export default UserDelete; diff --git a/src/app/settings/views/Users/UserDelete/index.ts b/src/app/settings/views/Users/UserDelete/index.ts new file mode 100644 index 0000000000..5ec02b0eb6 --- /dev/null +++ b/src/app/settings/views/Users/UserDelete/index.ts @@ -0,0 +1 @@ +export { default } from "./UserDelete"; diff --git a/src/app/settings/views/Users/UserDeleteForm/UserDeleteForm.tsx b/src/app/settings/views/Users/UserDeleteForm/UserDeleteForm.tsx new file mode 100644 index 0000000000..59dda011a8 --- /dev/null +++ b/src/app/settings/views/Users/UserDeleteForm/UserDeleteForm.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; + +import { Col, Row } from "@canonical/react-components"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom-v5-compat"; + +import FormikForm from "@/app/base/components/FormikForm"; +import { useAddMessage } from "@/app/base/hooks"; +import type { EmptyObject } from "@/app/base/types"; +import settingsURLs from "@/app/settings/urls"; +import { actions as userActions } from "@/app/store/user"; +import userSelectors from "@/app/store/user/selectors"; +import type { User } from "@/app/store/user/types"; + +type UserDeleteProps = { + user: User; +}; + +const UserDeleteForm = ({ user }: UserDeleteProps) => { + const [deletedUser, setDeletedUser] = useState(null); + const navigate = useNavigate(); + const saved = useSelector(userSelectors.saved); + const saving = useSelector(userSelectors.saving); + const errors = useSelector(userSelectors.errors); + const dispatch = useDispatch(); + + useAddMessage( + saved && !errors, + userActions.cleanup, + `Deleted ${deletedUser} from list` + ); + + return ( + + aria-label="Delete user" + initialValues={{}} + onCancel={() => navigate({ pathname: settingsURLs.users.index })} + onSubmit={() => { + dispatch(userActions.delete(user.id)); + setDeletedUser(user.username); + }} + saved={saved} + savedRedirect={settingsURLs.users.index} + saving={saving} + submitAppearance="negative" + submitLabel="Delete" + > + + +

    + {`Are you sure you want to delete \`${user.username}\`?`} +

    + + This action is permanent and can not be undone. + + +
    + + ); +}; + +export default UserDeleteForm; diff --git a/src/app/settings/views/Users/UserDeleteForm/index.ts b/src/app/settings/views/Users/UserDeleteForm/index.ts new file mode 100644 index 0000000000..39e0db39a2 --- /dev/null +++ b/src/app/settings/views/Users/UserDeleteForm/index.ts @@ -0,0 +1 @@ +export { default } from "./UserDeleteForm"; diff --git a/src/app/settings/views/Users/UserForm/UserForm.tsx b/src/app/settings/views/Users/UserForm/UserForm.tsx index 2fff1f62e4..b667dcc6d2 100644 --- a/src/app/settings/views/Users/UserForm/UserForm.tsx +++ b/src/app/settings/views/Users/UserForm/UserForm.tsx @@ -3,10 +3,9 @@ import { useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom-v5-compat"; -import FormCard from "@/app/base/components/FormCard"; import BaseUserForm from "@/app/base/components/UserForm"; import type { Props as UserFormProps } from "@/app/base/components/UserForm/UserForm"; -import { useAddMessage, useWindowTitle } from "@/app/base/hooks"; +import { useAddMessage } from "@/app/base/hooks"; import settingsURLs from "@/app/settings/urls"; import { actions as authActions } from "@/app/store/auth"; import { actions as userActions } from "@/app/store/user"; @@ -30,8 +29,6 @@ export const UserForm = ({ user }: PropTypes): JSX.Element => { const editing = !!user; const title = editing ? `Editing \`${name}\`` : "Add user"; - useWindowTitle(title); - useAddMessage( saved, userActions.cleanup, @@ -40,55 +37,53 @@ export const UserForm = ({ user }: PropTypes): JSX.Element => { ); return ( - - navigate(-1)} - onSave={(values) => { - const params = { - email: values.email, - is_superuser: values.isSuperuser, - last_name: values.fullName, - username: values.username, - }; - if (editing && user) { - dispatch(userActions.update({ ...params, id: user.id })); - if (values.password && values.passwordConfirm) { - dispatch( - authActions.adminChangePassword({ - ...params, - id: user.id, - password1: values.password, - password2: values.passwordConfirm, - }) - ); - } - } else if (!editing && values.password && values.passwordConfirm) { + navigate(-1)} + onSave={(values) => { + const params = { + email: values.email, + is_superuser: values.isSuperuser, + last_name: values.fullName, + username: values.username, + }; + if (editing && user) { + dispatch(userActions.update({ ...params, id: user.id })); + if (values.password && values.passwordConfirm) { dispatch( - userActions.create({ + authActions.adminChangePassword({ ...params, + id: user.id, password1: values.password, password2: values.passwordConfirm, }) ); } - setSaving(values.username); - }} - onSaveAnalytics={{ - action: "Saved", - category: "Users settings", - label: `${editing ? "Edit" : "Add"} user form`, - }} - onUpdateFields={(values) => { - setName(values.username); - }} - savedRedirect={settingsURLs.users.index} - submitLabel="Save user" - user={user} - /> - + } else if (!editing && values.password && values.passwordConfirm) { + dispatch( + userActions.create({ + ...params, + password1: values.password, + password2: values.passwordConfirm, + }) + ); + } + setSaving(values.username); + }} + onSaveAnalytics={{ + action: "Saved", + category: "Users settings", + label: `${editing ? "Edit" : "Add"} user form`, + }} + onUpdateFields={(values) => { + setName(values.username); + }} + savedRedirect={settingsURLs.users.index} + submitLabel="Save user" + user={user} + /> ); }; diff --git a/src/app/settings/views/Users/UsersList/UsersList.test.tsx b/src/app/settings/views/Users/UsersList/UsersList.test.tsx index 7dc22c00ae..b30842e856 100644 --- a/src/app/settings/views/Users/UsersList/UsersList.test.tsx +++ b/src/app/settings/views/Users/UsersList/UsersList.test.tsx @@ -62,90 +62,6 @@ describe("UsersList", () => { }); }); - it("can show a delete confirmation", async () => { - const store = mockStore(state); - const { rerender } = render( - - - - - - - - ); - let row = screen.getAllByTestId("user-row")[1]; - expect(row).not.toHaveClass("is-active"); - - // Click on the delete button: - await userEvent.click(within(row).getByTestId("table-actions-delete")); - - rerender( - - - - - - - - ); - - row = screen.getAllByTestId("user-row")[1]; - expect(row).toHaveClass("is-active"); - }); - - it("can delete a user", async () => { - const store = mockStore(state); - const { rerender } = render( - - - - - - - - ); - let row = screen.getAllByTestId("user-row")[1]; - - // Click on the delete button: - await userEvent.click(within(row).getByTestId("table-actions-delete")); - - rerender( - - - - - - - - ); - - row = screen.getAllByTestId("user-row")[1]; - - // Click on the delete confirm button - await userEvent.click(within(row).getByTestId("action-confirm")); - - expect(store.getActions()[1]).toEqual({ - type: "user/delete", - payload: { - params: { - id: 2, - }, - }, - meta: { - model: "user", - method: "delete", - }, - }); - }); - it("disables delete for the current user", () => { renderWithMockStore( { { state } ); let row = screen.getAllByTestId("user-row")[0]; - expect(within(row).getByTestId("table-actions-delete")).toBeDisabled(); + expect(within(row).getByRole("link", { name: /delete/i })).toHaveAttribute( + "aria-disabled", + "true" + ); }); it("links to preferences for the current user", () => { diff --git a/src/app/settings/views/Users/UsersList/UsersList.tsx b/src/app/settings/views/Users/UsersList/UsersList.tsx index 40143650c5..2b2b00aa37 100644 --- a/src/app/settings/views/Users/UsersList/UsersList.tsx +++ b/src/app/settings/views/Users/UsersList/UsersList.tsx @@ -4,10 +4,8 @@ import { ContentSection } from "@canonical/maas-react-components"; import { Notification } from "@canonical/react-components"; import { format, parse } from "date-fns"; import { useDispatch, useSelector } from "react-redux"; -import type { Dispatch } from "redux"; import TableActions from "@/app/base/components/TableActions"; -import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm"; import TableHeader from "@/app/base/components/TableHeader"; import { useFetchActions, @@ -24,7 +22,7 @@ import type { RootState } from "@/app/store/root/types"; import statusSelectors from "@/app/store/status/selectors"; import { actions as userActions } from "@/app/store/user"; import userSelectors from "@/app/store/user/selectors"; -import type { User, UserMeta, UserState } from "@/app/store/user/types"; +import type { User } from "@/app/store/user/types"; import { isComparable } from "@/app/utils"; type SortKey = keyof User; @@ -32,16 +30,9 @@ type SortKey = keyof User; const generateUserRows = ( users: User[], authUser: User | null, - expandedId: User[UserMeta.PK] | null, - setExpandedId: (expandedId: User[UserMeta.PK] | null) => void, - dispatch: Dispatch, - displayUsername: boolean, - setDeleting: (deletingUser: User["username"] | null) => void, - saved: UserState["saved"], - saving: UserState["saving"] + displayUsername: boolean ) => users.map((user) => { - const expanded = expandedId === user.id; const isAuthUser = user.id === authUser?.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const last_login = user.last_login @@ -52,7 +43,7 @@ const generateUserRows = ( : "Never"; const fullName = user.last_name; return { - className: expanded ? "p-table__row is-active" : "p-table__row", + className: "p-table__row", columns: [ { content: displayUsername ? user.username : fullName || <>—, @@ -73,6 +64,7 @@ const generateUserRows = ( content: ( setExpandedId(user.id)} /> ), className: "u-align--right", }, ], "data-testid": "user-row", - expanded: expanded, - expandedContent: expanded && ( - setExpandedId(null)} - onConfirm={() => { - dispatch(userActions.delete(user.id)); - setDeleting(user.username); - }} - /> - ), key: user.username, sortData: { username: user.username, @@ -122,7 +99,6 @@ const getSortValue = (sortKey: SortKey, user: User) => { }; const UsersList = (): JSX.Element => { - const [expandedId, setExpandedId] = useState(null); const [searchText, setSearchText] = useState(""); const [displayUsername, setDisplayUsername] = useState(true); const [deletingUser, setDeleting] = useState(null); @@ -133,7 +109,6 @@ const UsersList = (): JSX.Element => { const loaded = useSelector(userSelectors.loaded); const authUser = useSelector(authSelectors.get); const saved = useSelector(userSelectors.saved); - const saving = useSelector(userSelectors.saving); const externalAuthURL = useSelector(statusSelectors.externalAuthURL); const dispatch = useDispatch(); @@ -254,17 +229,7 @@ const UsersList = (): JSX.Element => { ]} loaded={loaded} loading={loading} - rows={generateUserRows( - sortedUsers, - authUser, - expandedId, - setExpandedId, - dispatch, - displayUsername, - setDeleting, - saved, - saving - )} + rows={generateUserRows(sortedUsers, authUser, displayUsername)} searchOnChange={setSearchText} searchPlaceholder="Search users" searchText={searchText} diff --git a/src/app/store/machine/types/base.ts b/src/app/store/machine/types/base.ts index 73ab35de76..23f0c91de1 100644 --- a/src/app/store/machine/types/base.ts +++ b/src/app/store/machine/types/base.ts @@ -371,3 +371,9 @@ export type MachineState = { selected: SelectedMachines | null; statuses: MachineStatuses; } & GenericState; + +export type StorageLayoutOption = { + label: string; + sentenceLabel: string; + value: StorageLayout; +}; diff --git a/src/app/store/machine/types/index.ts b/src/app/store/machine/types/index.ts index 8ba79ba7eb..42a082c018 100644 --- a/src/app/store/machine/types/index.ts +++ b/src/app/store/machine/types/index.ts @@ -67,6 +67,7 @@ export type { MachineStatus, MachineStatuses, SelectedMachines, + StorageLayoutOption, } from "./base"; export { FilterGroupKey, FilterGroupType } from "./base"; diff --git a/src/app/store/utils/node/base.ts b/src/app/store/utils/node/base.ts index 2a6088145a..0c148c1022 100644 --- a/src/app/store/utils/node/base.ts +++ b/src/app/store/utils/node/base.ts @@ -190,24 +190,54 @@ export const getSidePanelTitle = ( if (sidePanelContent) { const [, name] = sidePanelContent.view; switch (name) { + case SidePanelViews.ADD_ALIAS[1]: + return "Add alias"; + case SidePanelViews.ADD_BOND[1]: + return "Create bond"; + case SidePanelViews.ADD_BRIDGE[1]: + return "Create bridge"; case SidePanelViews.ADD_CONTROLLER[1]: return "Add controller"; case SidePanelViews.ADD_CHASSIS[1]: return "Add chassis"; + case SidePanelViews.ADD_INTERFACE[1]: + return "Add interface"; case SidePanelViews.ADD_MACHINE[1]: return "Add machine"; case SidePanelViews.ADD_DEVICE[1]: return "Add device"; + case SidePanelViews.ADD_SPECIAL_FILESYSTEM[1]: + return "Add special filesystem"; case SidePanelViews.AddTag[1]: return "Create new tag"; + case SidePanelViews.ADD_VLAN[1]: + return "Add VLAN"; + case SidePanelViews.CHANGE_SOURCE[1]: + return "Change source"; + case SidePanelViews.CREATE_DATASTORE[1]: + return "Create datastore"; + case SidePanelViews.CREATE_RAID[1]: + return "Create raid"; + case SidePanelViews.CREATE_VOLUME_GROUP[1]: + return "Create volume group"; case SidePanelViews.DeleteTag[1]: return "Delete tag"; + case SidePanelViews.EDIT_INTERFACE[1]: + return "Edit interface"; case SidePanelViews.CREATE_ZONE[1]: return "Add AZ"; + case SidePanelViews.DELETE_IMAGE[1]: + return "Delete image"; case SidePanelViews.DELETE_SPACE[1]: return "Delete space"; case SidePanelViews.DELETE_FABRIC[1]: return "Delete fabric"; + case SidePanelViews.REMOVE_INTERFACE[1]: + return "Remove interface"; + case SidePanelViews.UPDATE_DATASTORE[1]: + return "Update datastore"; + case SidePanelViews.UpdateTag[1]: + return "Update Tag"; default: return name ? getNodeActionTitle(name as NodeActions) : defaultTitle; } diff --git a/src/app/subnets/components/ReservedRangeDeleteForm/ReservedRangeDeleteForm.tsx b/src/app/subnets/components/ReservedRangeDeleteForm/ReservedRangeDeleteForm.tsx new file mode 100644 index 0000000000..ed6340dd1e --- /dev/null +++ b/src/app/subnets/components/ReservedRangeDeleteForm/ReservedRangeDeleteForm.tsx @@ -0,0 +1,45 @@ +import { useDispatch, useSelector } from "react-redux"; + +import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; +import { + useSidePanel, + type SetSidePanelContent, +} from "@/app/base/side-panel-context"; +import { actions as ipRangeActions } from "@/app/store/iprange"; +import ipRangeSelectors from "@/app/store/iprange/selectors"; + +type Props = { + setActiveForm: SetSidePanelContent; +}; + +const ReservedRangeDeleteForm = ({ setActiveForm }: Props) => { + const dispatch = useDispatch(); + const { sidePanelContent } = useSidePanel(); + const saved = useSelector(ipRangeSelectors.saved); + const saving = useSelector(ipRangeSelectors.saving); + const ipRangeId = + sidePanelContent?.extras && "ipRangeId" in sidePanelContent.extras + ? sidePanelContent?.extras?.ipRangeId + : null; + + if (!ipRangeId && ipRangeId !== 0) { + return

    IP range not provided

    ; + } + + return ( + setActiveForm(null)} + onSubmit={() => { + dispatch(ipRangeActions.delete(ipRangeId)); + }} + saved={saved} + saving={saving} + /> + ); +}; + +export default ReservedRangeDeleteForm; diff --git a/src/app/subnets/components/ReservedRangeDeleteForm/index.ts b/src/app/subnets/components/ReservedRangeDeleteForm/index.ts new file mode 100644 index 0000000000..98e3b4dd47 --- /dev/null +++ b/src/app/subnets/components/ReservedRangeDeleteForm/index.ts @@ -0,0 +1 @@ +export { default } from "./ReservedRangeDeleteForm"; diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx index c68ae65908..269098a942 100644 --- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx +++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx @@ -45,7 +45,7 @@ describe("ReservedRangeForm", () => { initialEntries={[{ pathname: "/machines", key: "testKey" }]} > - +
    @@ -61,7 +61,7 @@ describe("ReservedRangeForm", () => { initialEntries={[{ pathname: "/machines", key: "testKey" }]} > - + @@ -77,7 +77,7 @@ describe("ReservedRangeForm", () => { initialEntries={[{ pathname: "/machines", key: "testKey" }]} > - + @@ -104,7 +104,7 @@ describe("ReservedRangeForm", () => { initialEntries={[{ pathname: "/machines", key: "testKey" }]} > - + @@ -127,8 +127,8 @@ describe("ReservedRangeForm", () => { @@ -169,7 +169,7 @@ describe("ReservedRangeForm", () => { initialEntries={[{ pathname: "/machines", key: "testKey" }]} > - + @@ -201,7 +201,7 @@ describe("ReservedRangeForm", () => { initialEntries={[{ pathname: "/machines", key: "testKey" }]} > - + @@ -236,7 +236,7 @@ describe("ReservedRangeForm", () => { diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx index 01e01f8166..e2a054a188 100644 --- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx +++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx @@ -6,6 +6,10 @@ import * as Yup from "yup"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; +import { + useSidePanel, + type SetSidePanelContent, +} from "@/app/base/side-panel-context"; import { actions as ipRangeActions } from "@/app/store/iprange"; import ipRangeSelectors from "@/app/store/iprange/selectors"; import type { IPRange } from "@/app/store/iprange/types"; @@ -16,9 +20,9 @@ import { isId } from "@/app/utils"; type Props = { createType?: IPRangeType; - id?: IPRange[IPRangeMeta.PK] | null; - onClose: () => void; - subnetId?: Subnet[SubnetMeta.PK] | null; + ipRangeId?: IPRange[IPRangeMeta.PK] | null; + setActiveForm: SetSidePanelContent; + id?: Subnet[SubnetMeta.PK] | null; }; export type FormValues = { @@ -43,20 +47,36 @@ const Schema = Yup.object().shape({ const ReservedRangeForm = ({ createType, + ipRangeId, + setActiveForm, id, - onClose, - subnetId, ...props }: Props): JSX.Element | null => { const dispatch = useDispatch(); + const { sidePanelContent } = useSidePanel(); + let computedIpRangeId = ipRangeId; + if (!ipRangeId) { + computedIpRangeId = + sidePanelContent?.extras && "ipRangeId" in sidePanelContent.extras + ? sidePanelContent?.extras?.ipRangeId + : undefined; + } const ipRange = useSelector((state: RootState) => - ipRangeSelectors.getById(state, id) + ipRangeSelectors.getById(state, computedIpRangeId) ); const saved = useSelector(ipRangeSelectors.saved); const saving = useSelector(ipRangeSelectors.saving); const errors = useSelector(ipRangeSelectors.errors); const cleanup = useCallback(() => ipRangeActions.cleanup(), []); - const isEditing = isId(id); + const isEditing = isId(computedIpRangeId); + const onClose = () => setActiveForm(null); + let computedCreateType = createType; + if (!createType) { + computedCreateType = + sidePanelContent?.extras && "createType" in sidePanelContent.extras + ? sidePanelContent?.extras?.createType + : undefined; + } if (isEditing && !ipRange) { return ( @@ -92,11 +112,11 @@ const ReservedRangeForm = ({ onSubmit={(values) => { // Clear the errors from the previous submission. dispatch(cleanup()); - if (!isEditing && createType) { + if (!isEditing && computedCreateType) { dispatch( ipRangeActions.create({ - subnet: subnetId, - type: createType, + subnet: id, + type: computedCreateType, ...values, }) ); @@ -120,7 +140,7 @@ const ReservedRangeForm = ({ {...props} > - + - + - {isEditing || createType === IPRangeType.Reserved ? ( - + {isEditing || computedCreateType === IPRangeType.Reserved ? ( + { ).toHaveTextContent("what a beaut"); }); -it("displays an edit form", async () => { - const store = mockStore(state); - render( - - - - - - - - ); - await userEvent.click(screen.getByRole("button", { name: "Edit" })); - - await waitFor(() => - expect( - screen.getByRole("form", { name: ReservedRangeFormLabels.EditRange }) - ).toBeInTheDocument() - ); -}); - -it("displays confirm delete message", async () => { - const store = mockStore(state); - render( - - - - - - - - ); - await userEvent.click(screen.getByRole("button", { name: "Delete" })); - - await waitFor(() => { - expect( - screen.getByText( - new RegExp("Are you sure you want to remove this IP range?") - ) - ).toBeInTheDocument(); - }); -}); - -it("dispatches an action to delete a reserved range", async () => { - const store = mockStore(state); - render( - - - - - - - - ); - await userEvent.click(screen.getByTestId("table-actions-delete")); - await userEvent.click(screen.getByTestId("action-confirm")); - - const expectedAction = ipRangeActions.delete(ipRange.id); - - await waitFor(() => { - const actualAction = store - .getActions() - .find((action) => action.type === expectedAction.type); - expect(actualAction).toStrictEqual(expectedAction); - }); -}); - it("displays an add button when it is reserved", () => { ipRange.type = IPRangeType.Reserved; state.iprange.items = [ipRange]; @@ -375,33 +306,6 @@ it("disables the add button if there are no subnets in a VLAN", () => { ).toHaveAttribute("disabled"); }); -it("can display an add form", async () => { - const store = mockStore(state); - render( - - - - - - - - ); - await userEvent.click( - screen.queryAllByRole("button", { - name: Labels.ReserveRange, - })[0] - ); - await userEvent.click(screen.getByTestId("reserve-range-menu-item")); - - await waitFor(() => { - expect( - screen.getByRole("form", { - name: ReservedRangeFormLabels.CreateRange, - }) - ).toBeInTheDocument(); - }); -}); - it("displays the subnet column when the table is for a VLAN", () => { state.iprange.items = [ ipRangeFactory({ start_ip: "11.1.1.1", vlan: vlan.id }), diff --git a/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx b/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx index 486faba939..dd08441425 100644 --- a/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx +++ b/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx @@ -9,21 +9,18 @@ import { } from "@canonical/react-components"; import type { MainTableCell } from "@canonical/react-components/dist/components/MainTable/MainTable"; import classNames from "classnames"; -import { useDispatch, useSelector } from "react-redux"; -import type { Dispatch } from "redux"; +import { useSelector } from "react-redux"; -import ReservedRangeForm from "../ReservedRangeForm"; - -import FormCard from "@/app/base/components/FormCard"; import SubnetLink from "@/app/base/components/SubnetLink"; import TableActions from "@/app/base/components/TableActions"; -import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm"; import TitledSection from "@/app/base/components/TitledSection"; import docsUrls from "@/app/base/docsUrls"; import { useFetchActions } from "@/app/base/hooks"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import { useSidePanel } from "@/app/base/side-panel-context"; import { actions as ipRangeActions } from "@/app/store/iprange"; import ipRangeSelectors from "@/app/store/iprange/selectors"; -import type { IPRange, IPRangeMeta } from "@/app/store/iprange/types"; +import type { IPRange } from "@/app/store/iprange/types"; import { IPRangeType } from "@/app/store/iprange/types"; import { getCommentDisplay, @@ -33,6 +30,10 @@ import { import type { RootState } from "@/app/store/root/types"; import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; import type { VLAN, VLANMeta } from "@/app/store/vlan/types"; +import { + SubnetActionTypes, + SubnetDetailsSidePanelViews, +} from "@/app/subnets/views/SubnetDetails/constants"; import { generateEmptyStateMsg, getTableStatus, isId } from "@/app/utils"; export type SubnetProps = { @@ -68,58 +69,17 @@ export enum ExpandedType { Update, } -type Expanded = { - id?: IPRange[IPRangeMeta.PK]; - type: ExpandedType; -}; - -const toggleExpanded = ( - id: IPRange[IPRangeMeta.PK], - expanded: Expanded | null, - expandedType: ExpandedType, - setExpanded: (expanded: Expanded | null) => void -) => - setExpanded( - expanded?.id === id && expanded.type === expandedType - ? null - : { - id, - type: expandedType, - } - ); - const generateRows = ( - dispatch: Dispatch, ipRanges: IPRange[], - expanded: Expanded | null, - setExpanded: (expanded: Expanded | null) => void, - saved: boolean, - saving: boolean, - showSubnetColumn: boolean + showSubnetColumn: boolean, + setSidePanelContent: SetSidePanelContent ) => ipRanges.map((ipRange: IPRange) => { - const isExpanded = expanded?.id === ipRange.id; const comment = getCommentDisplay(ipRange); const owner = getOwnerDisplay(ipRange); const type = getTypeDisplay(ipRange); let expandedContent: ReactNode | null = null; - const onClose = () => setExpanded(null); - if (expanded?.type === ExpandedType.Delete) { - expandedContent = ( - { - dispatch(ipRangeActions.delete(ipRange.id)); - }} - sidebar={false} - /> - ); - } else if (expanded?.type === ExpandedType.Update) { - expandedContent = ; - } + const columns: MainTableCell[] = [ { "aria-label": Labels.StartIP, @@ -151,22 +111,26 @@ const generateRows = ( className: "actions-col u-align--right", content: ( { - toggleExpanded( - ipRange.id, - expanded, - ExpandedType.Delete, - setExpanded - ); - }} - onEdit={() => { - toggleExpanded( - ipRange.id, - expanded, - ExpandedType.Update, - setExpanded - ); - }} + onDelete={() => + setSidePanelContent({ + view: SubnetDetailsSidePanelViews[ + SubnetActionTypes.DeleteReservedRange + ], + extras: { + ipRangeId: ipRange.id, + }, + }) + } + onEdit={() => + setSidePanelContent({ + view: SubnetDetailsSidePanelViews[ + SubnetActionTypes.ReserveRange + ], + extras: { + ipRangeId: ipRange.id, + }, + }) + } /> ), }, @@ -179,9 +143,7 @@ const generateRows = ( }); } return { - className: isExpanded ? "p-table__row is-active" : null, columns, - expanded: isExpanded, expandedContent: expandedContent, key: ipRange.id, sortData: { @@ -199,19 +161,15 @@ const ReservedRanges = ({ subnetId, vlanId, }: Props): JSX.Element | null => { - const dispatch = useDispatch(); - const [expanded, setExpanded] = useState(null); + const [isAddingDynamic, setIsAddingDynamic] = useState(false); + const { setSidePanelContent } = useSidePanel(); const isSubnet = isId(subnetId); const ipRangeLoading = useSelector(ipRangeSelectors.loading); - const saved = useSelector(ipRangeSelectors.saved); - const saving = useSelector(ipRangeSelectors.saving); const ipRanges = useSelector((state: RootState) => isSubnet ? ipRangeSelectors.getBySubnet(state, subnetId) : ipRangeSelectors.getByVLAN(state, vlanId) ); - const isAddingDynamic = expanded?.type === ExpandedType.CreateDynamic; - const isAdding = expanded?.type === ExpandedType.Create || isAddingDynamic; const isDisabled = isId(vlanId) && !hasVLANSubnets; const showSubnetColumn = isId(vlanId); @@ -268,12 +226,32 @@ const ReservedRanges = ({ { children: Labels.ReserveRange, "data-testid": "reserve-range-menu-item", - onClick: () => setExpanded({ type: ExpandedType.Create }), + onClick: () => { + setSidePanelContent({ + view: SubnetDetailsSidePanelViews[ + SubnetActionTypes.ReserveRange + ], + extras: { + createType: IPRangeType.Reserved, + }, + }); + setIsAddingDynamic(false); + }, }, { children: Labels.ReserveDynamicRange, "data-testid": "reserve-dynamic-range-menu-item", - onClick: () => setExpanded({ type: ExpandedType.CreateDynamic }), + onClick: () => { + setSidePanelContent({ + view: SubnetDetailsSidePanelViews[ + SubnetActionTypes.ReserveRange + ], + extras: { + createType: IPRangeType.Dynamic, + }, + }); + setIsAddingDynamic(true); + }, }, ]} position="right" @@ -309,28 +287,9 @@ const ReservedRanges = ({ expanding headers={headers} responsive - rows={generateRows( - dispatch, - ipRanges, - expanded, - setExpanded, - saved, - saving, - showSubnetColumn - )} + rows={generateRows(ipRanges, showSubnetColumn, setSidePanelContent)} sortable /> - {isAdding ? ( - - setExpanded(null)} - subnetId={subnetId} - /> - - ) : null} About IP ranges ); diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx index 9645367527..1261bb75e0 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx @@ -46,7 +46,7 @@ it("dispatches a correct action on add static route form submit", async () => { - + diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx index e2d9fd34c4..492225472f 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx @@ -8,6 +8,7 @@ import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; import SubnetSelect from "@/app/base/components/SubnetSelect"; import { useFetchActions } from "@/app/base/hooks"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; import type { RootState } from "@/app/store/root/types"; import { actions as staticRouteActions } from "@/app/store/staticroute"; import staticRouteSelectors from "@/app/store/staticroute/selectors"; @@ -37,22 +38,23 @@ const addStaticRouteSchema = Yup.object().shape({ }); export type Props = { - subnetId: Subnet[SubnetMeta.PK]; - handleDismiss: () => void; + id: Subnet[SubnetMeta.PK]; + setActiveForm: SetSidePanelContent; }; const AddStaticRouteForm = ({ - subnetId, - handleDismiss, + id, + setActiveForm, }: Props): JSX.Element | null => { const staticRouteErrors = useSelector(staticRouteSelectors.errors); const saving = useSelector(staticRouteSelectors.saving); const saved = useSelector(staticRouteSelectors.saved); const dispatch = useDispatch(); + const handleClose = () => setActiveForm(null); const staticRoutesLoading = useSelector(staticRouteSelectors.loading); const subnetsLoading = useSelector(subnetSelectors.loading); const loading = staticRoutesLoading || subnetsLoading; const source = useSelector((state: RootState) => - subnetSelectors.getById(state, subnetId) + subnetSelectors.getById(state, id) ); useFetchActions([subnetActions.fetch]); @@ -67,12 +69,12 @@ const AddStaticRouteForm = ({ cleanup={staticRouteActions.cleanup} errors={staticRouteErrors} initialValues={{ - source: subnetId, + source: id, gateway_ip: "", destination: "", metric: "0", }} - onCancel={handleDismiss} + onCancel={handleClose} onSaveAnalytics={{ action: AddStaticRouteFormLabels.Save, category: "Subnet", @@ -82,7 +84,7 @@ const AddStaticRouteForm = ({ dispatch(staticRouteActions.cleanup()); dispatch( staticRouteActions.create({ - source: subnetId, + source: id, gateway_ip, destination: toFormikNumber(destination) as number, metric: toFormikNumber(metric), @@ -90,7 +92,7 @@ const AddStaticRouteForm = ({ ); }} onSuccess={() => { - handleDismiss(); + handleClose(); }} resetOnSave saved={saved} @@ -99,10 +101,10 @@ const AddStaticRouteForm = ({ validationSchema={addStaticRouteSchema} > - + - + - + diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx new file mode 100644 index 0000000000..0d7508f76e --- /dev/null +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx @@ -0,0 +1,63 @@ +import configureStore from "redux-mock-store"; + +import DeleteStaticRouteForm from "./DeleteStaticRouteForm"; + +import type { RootState } from "@/app/store/root/types"; +import { actions as staticRouteActions } from "@/app/store/staticroute"; +import { + rootState as rootStateFactory, + staticRouteState as staticRouteStateFactory, + subnet as subnetFactory, + subnetState as subnetStateFactory, + authState as authStateFactory, + user as userFactory, + userState as userStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +let state: RootState; +const mockStore = configureStore(); + +const subnet = subnetFactory({ id: 1, cidr: "172.16.1.0/24" }); +const destinationSubnet = subnetFactory({ id: 2, cidr: "223.16.1.0/24" }); +state = rootStateFactory({ + user: userStateFactory({ + auth: authStateFactory({ + user: userFactory(), + }), + items: [userFactory(), userFactory(), userFactory()], + }), + staticroute: staticRouteStateFactory({ + loaded: true, + items: [], + }), + subnet: subnetStateFactory({ + loaded: true, + items: [subnet, destinationSubnet], + }), +}); + +it("renders", () => { + renderWithBrowserRouter( + , + { state } + ); + + expect(screen.getByRole("form", { name: "Confirm static route deletion" })); +}); + +it("dispatches the correct action to delete a static route", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { store } + ); + + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + + const action = store + .getActions() + .find((action) => action.type === staticRouteActions.delete.type); + + expect(action).toStrictEqual(staticRouteActions.delete(subnet.id)); +}); diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx new file mode 100644 index 0000000000..a61d59f6f2 --- /dev/null +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx @@ -0,0 +1,33 @@ +import { useDispatch, useSelector } from "react-redux"; + +import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import { actions as staticRouteActions } from "@/app/store/staticroute"; +import staticRouteSelectors from "@/app/store/staticroute/selectors"; +import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; + +type Props = { + id: Subnet[SubnetMeta.PK]; + setActiveForm: SetSidePanelContent; +}; + +const DeleteStaticRouteForm = ({ id, setActiveForm }: Props) => { + const dispatch = useDispatch(); + const saved = useSelector(staticRouteSelectors.saved); + const saving = useSelector(staticRouteSelectors.saving); + return ( + setActiveForm(null)} + onSubmit={() => { + dispatch(staticRouteActions.delete(id)); + }} + saved={saved} + saving={saving} + /> + ); +}; + +export default DeleteStaticRouteForm; diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/index.ts b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/index.ts new file mode 100644 index 0000000000..2c376627a6 --- /dev/null +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteStaticRouteForm"; diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx index ceb8b9dea5..b6454c129e 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx @@ -36,7 +36,7 @@ it("displays loading text on load", async () => { - + @@ -79,10 +79,7 @@ it("dispatches a correct action on edit static route form submit", async () => { - + diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx index 80ebc82eeb..51a3cf6b55 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx @@ -8,6 +8,7 @@ import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; import SubnetSelect from "@/app/base/components/SubnetSelect"; import { useFetchActions } from "@/app/base/hooks"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; import type { RootState } from "@/app/store/root/types"; import { actions as staticRouteActions } from "@/app/store/staticroute"; import staticRouteSelectors from "@/app/store/staticroute/selectors"; @@ -38,22 +39,23 @@ const editStaticRouteSchema = Yup.object().shape({ }); export type Props = { - staticRouteId: StaticRoute[StaticRouteMeta.PK]; - handleDismiss: () => void; + id: StaticRoute[StaticRouteMeta.PK]; + setActiveForm: SetSidePanelContent; }; const EditStaticRouteForm = ({ - staticRouteId, - handleDismiss, + id, + setActiveForm, }: Props): JSX.Element | null => { const staticRouteErrors = useSelector(staticRouteSelectors.errors); const saving = useSelector(staticRouteSelectors.saving); const saved = useSelector(staticRouteSelectors.saved); const dispatch = useDispatch(); + const handleClose = () => setActiveForm(null); const staticRoutesLoading = useSelector(staticRouteSelectors.loading); const subnetsLoading = useSelector(subnetSelectors.loading); const loading = staticRoutesLoading || subnetsLoading; const staticRoute = useSelector((state: RootState) => - staticRouteSelectors.getById(state, staticRouteId) + staticRouteSelectors.getById(state, id) ); const source = useSelector((state: RootState) => subnetSelectors.getById(state, staticRoute?.source) @@ -78,7 +80,7 @@ const EditStaticRouteForm = ({ destination: staticRoute.destination, metric: staticRoute.metric, }} - onCancel={handleDismiss} + onCancel={handleClose} onSaveAnalytics={{ action: EditStaticRouteFormLabels.Save, category: "Subnet", @@ -88,7 +90,7 @@ const EditStaticRouteForm = ({ dispatch(staticRouteActions.cleanup()); dispatch( staticRouteActions.update({ - id: staticRouteId, + id: id, source: staticRoute.source, gateway_ip, destination: toFormikNumber(destination) as number, @@ -97,7 +99,7 @@ const EditStaticRouteForm = ({ ); }} onSuccess={() => { - handleDismiss(); + handleClose(); }} resetOnSave saved={saved} @@ -106,10 +108,10 @@ const EditStaticRouteForm = ({ validationSchema={editStaticRouteSchema} > - + - + - + diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx index 68b63d44f1..3e0e3539d0 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx @@ -4,7 +4,6 @@ import { CompatRouter } from "react-router-dom-v5-compat"; import configureStore from "redux-mock-store"; import { AddStaticRouteFormLabels } from "./AddStaticRouteForm/AddStaticRouteForm"; -import { EditStaticRouteFormLabels } from "./EditStaticRouteForm/EditStaticRouteForm"; import StaticRoutes, { Labels } from "./StaticRoutes"; import { @@ -17,7 +16,7 @@ import { user as userFactory, userState as userStateFactory, } from "@/testing/factories"; -import { userEvent, render, screen, waitFor, within } from "@/testing/utils"; +import { render, screen, waitFor } from "@/testing/utils"; const mockStore = configureStore(); @@ -72,7 +71,7 @@ it("renders for a subnet", () => { ).toBeInTheDocument(); }); -it("can open and close the add static route form", async () => { +it("has a button to open the static route form", async () => { const subnet = subnetFactory({ id: 1 }); const state = rootStateFactory({ user: userStateFactory({ @@ -106,37 +105,9 @@ it("can open and close the add static route form", async () => { }) ).toBeInTheDocument() ); - await userEvent.click( - screen.getByRole("button", { - name: AddStaticRouteFormLabels.AddStaticRoute, - }) - ); - await waitFor(() => - expect( - screen.getByRole("form", { - name: AddStaticRouteFormLabels.AddStaticRoute, - }) - ) - ); - - await userEvent.click( - within( - screen.getByRole("form", { - name: AddStaticRouteFormLabels.AddStaticRoute, - }) - ).getByRole("button", { name: "Cancel" }) - ); - - await waitFor(() => - expect( - screen.queryByRole("form", { - name: AddStaticRouteFormLabels.AddStaticRoute, - }) - ).not.toBeInTheDocument() - ); }); -it("can open and close the edit static route form", async () => { +it("has a button to open the edit static route form", async () => { const subnet = subnetFactory({ id: 1 }); const state = rootStateFactory({ staticroute: staticRouteStateFactory({ @@ -163,33 +134,9 @@ it("can open and close the edit static route form", async () => { ); - await userEvent.click( + expect( screen.getByRole("button", { name: "Edit", }) - ); - - await waitFor(() => - expect( - screen.getByRole("form", { - name: EditStaticRouteFormLabels.EditStaticRoute, - }) - ).toBeInTheDocument() - ); - - await userEvent.click( - within( - screen.getByRole("form", { - name: EditStaticRouteFormLabels.EditStaticRoute, - }) - ).getByRole("button", { name: "Cancel" }) - ); - - await waitFor(() => - expect( - screen.queryByRole("form", { - name: EditStaticRouteFormLabels.EditStaticRoute, - }) - ).not.toBeInTheDocument() - ); + ).toBeInTheDocument(); }); diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx index 3cb4d308c3..06c1d51b92 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx @@ -1,26 +1,18 @@ -import type { ReactNode } from "react"; -import { useState } from "react"; - import { Button, MainTable, Spinner } from "@canonical/react-components"; -import { useDispatch, useSelector } from "react-redux"; -import type { Dispatch } from "redux"; +import { useSelector } from "react-redux"; -import AddStaticRouteForm from "./AddStaticRouteForm"; -import EditStaticRouteForm from "./EditStaticRouteForm"; +import { SubnetActionTypes, SubnetDetailsSidePanelViews } from "../constants"; -import FormCard from "@/app/base/components/FormCard"; import SubnetLink from "@/app/base/components/SubnetLink"; import TableActions from "@/app/base/components/TableActions"; -import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm"; import TitledSection from "@/app/base/components/TitledSection"; import { useFetchActions } from "@/app/base/hooks"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import { useSidePanel } from "@/app/base/side-panel-context"; import authSelectors from "@/app/store/auth/selectors"; import { actions as staticRouteActions } from "@/app/store/staticroute"; import staticRouteSelectors from "@/app/store/staticroute/selectors"; -import type { - StaticRoute, - StaticRouteMeta, -} from "@/app/store/staticroute/types"; +import type { StaticRoute } from "@/app/store/staticroute/types"; import { actions as subnetActions } from "@/app/store/subnet"; import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; @@ -43,72 +35,16 @@ export enum ExpandedType { Update, } -type Expanded = { - id?: StaticRoute[StaticRouteMeta.PK]; - type: ExpandedType; -}; - -const toggleExpanded = ( - id: StaticRoute[StaticRouteMeta.PK], - expanded: Expanded | null, - expandedType: ExpandedType, - setExpanded: (expanded: Expanded | null) => void -) => - setExpanded( - expanded?.id === id && expanded.type === expandedType - ? null - : { - id, - type: expandedType, - } - ); - const generateRows = ( - dispatch: Dispatch, staticRoutes: StaticRoute[], subnets: Subnet[], - expanded: Expanded | null, - setExpanded: (expanded: Expanded | null) => void, - saved: boolean, - saving: boolean + setSidePanelContent: SetSidePanelContent ) => staticRoutes.map((staticRoute: StaticRoute) => { const subnet = subnets.find( (subnet) => subnet.id === staticRoute.destination ); - const isExpanded = expanded?.id === staticRoute.id; - let expandedContent: ReactNode | null = null; - const onClose = () => setExpanded(null); - if (expanded?.type === ExpandedType.Delete) { - expandedContent = ( - { - dispatch(staticRouteActions.delete(staticRoute.id)); - }} - sidebar={false} - /> - ); - } else if (expanded?.type === ExpandedType.Update) { - expandedContent = ( - - toggleExpanded( - staticRoute.id, - expanded, - ExpandedType.Update, - setExpanded - ) - } - staticRouteId={staticRoute.id} - /> - ); - } return { - className: isExpanded ? "p-table__row is-active" : null, columns: [ { "aria-label": Labels.GatewayIp, @@ -127,28 +63,24 @@ const generateRows = ( content: ( { - toggleExpanded( - staticRoute.id, - expanded, - ExpandedType.Delete, - setExpanded - ); + setSidePanelContent({ + view: SubnetDetailsSidePanelViews[ + SubnetActionTypes.DeleteStaticRoute + ], + }); }} onEdit={() => { - toggleExpanded( - staticRoute.id, - expanded, - ExpandedType.Update, - setExpanded - ); + setSidePanelContent({ + view: SubnetDetailsSidePanelViews[ + SubnetActionTypes.EditStaticRoute + ], + }); }} /> ), className: "u-align--right", }, ], - expanded: isExpanded, - expandedContent: expandedContent, key: staticRoute.id, sortData: { destination: getSubnetDisplay(subnet), @@ -159,11 +91,8 @@ const generateRows = ( }); const StaticRoutes = ({ subnetId }: Props): JSX.Element | null => { - const dispatch = useDispatch(); - const [expanded, setExpanded] = useState(null); + const { sidePanelContent, setSidePanelContent } = useSidePanel(); const staticRoutesLoading = useSelector(staticRouteSelectors.loading); - const saved = useSelector(staticRouteSelectors.saved); - const saving = useSelector(staticRouteSelectors.saving); const staticRoutes = useSelector(staticRouteSelectors.all).filter( (staticRoute) => staticRoute.source === subnetId ); @@ -171,7 +100,9 @@ const StaticRoutes = ({ subnetId }: Props): JSX.Element | null => { const subnetsLoading = useSelector(subnetSelectors.loading); const isAdmin = useSelector(authSelectors.isAdmin); const loading = staticRoutesLoading || subnetsLoading; - const isAddStaticRouteOpen = expanded?.type === ExpandedType.Create; + const isAddStaticRouteOpen = + sidePanelContent?.view === + SubnetDetailsSidePanelViews[SubnetActionTypes.AddStaticRoute]; useFetchActions([staticRouteActions.fetch, subnetActions.fetch]); @@ -182,7 +113,11 @@ const StaticRoutes = ({ subnetId }: Props): JSX.Element | null => {