From 7d88fb2f479727f2d54d05f9c031a2a53c4b2a94 Mon Sep 17 00:00:00 2001 From: Jones Ogolo <47540149+Jay-Topher@users.noreply.github.com> Date: Fri, 15 Mar 2024 10:42:16 +0100 Subject: [PATCH] feat: move non-partition collapsible forms to side panel (#5349) --- .../TableActionsDropdown.test.tsx | 2 +- .../TableActionsDropdown.tsx | 8 +- .../AddPartitionFields/AddPartitionFields.tsx | 4 +- .../AvailableStorageTable.test.tsx | 219 +++--------------- .../AvailableStorageTable.tsx | 30 ++- .../CreateCacheSet/CreateCacheSet.test.tsx | 60 +++++ .../CreateCacheSet/CreateCacheSet.tsx | 44 ++++ .../CreateCacheSet/index.ts | 1 + .../DeleteDisk/DeleteDisk.test.tsx | 56 +++++ .../DeleteDisk/DeleteDisk.tsx | 46 ++++ .../AvailableStorageTable/DeleteDisk/index.ts | 1 + .../EditDiskFields/EditDiskFields.tsx | 4 +- .../SetBootDisk/SetBootDisk.test.tsx | 56 +++++ .../SetBootDisk/SetBootDisk.tsx | 44 ++++ .../SetBootDisk/index.ts | 1 + .../StorageDeviceActions.tsx | 11 +- .../components/MachineForms/MachineForms.tsx | 56 +++++ src/app/machines/constants.ts | 5 + src/app/machines/types.ts | 7 + src/app/store/utils/node/base.ts | 10 + 20 files changed, 468 insertions(+), 197 deletions(-) create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.test.tsx create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.tsx create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/index.ts create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.test.tsx create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.tsx create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/index.ts create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.test.tsx create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.tsx create mode 100644 src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/index.ts diff --git a/src/app/base/components/TableActionsDropdown/TableActionsDropdown.test.tsx b/src/app/base/components/TableActionsDropdown/TableActionsDropdown.test.tsx index 306d85f3ad..0dd9c2a804 100644 --- a/src/app/base/components/TableActionsDropdown/TableActionsDropdown.test.tsx +++ b/src/app/base/components/TableActionsDropdown/TableActionsDropdown.test.tsx @@ -59,6 +59,6 @@ describe("TableActionsDropdown", () => { // Open menu and click the actions await userEvent.click(screen.getByRole("button")); await userEvent.click(screen.getByRole("button", { name: "Action 1" })); - expect(onActionClick).toHaveBeenCalledWith("action-1"); + expect(onActionClick).toHaveBeenCalledWith("action-1", undefined); }); }); diff --git a/src/app/base/components/TableActionsDropdown/TableActionsDropdown.tsx b/src/app/base/components/TableActionsDropdown/TableActionsDropdown.tsx index 0e89ed3d76..7a2e10f021 100644 --- a/src/app/base/components/TableActionsDropdown/TableActionsDropdown.tsx +++ b/src/app/base/components/TableActionsDropdown/TableActionsDropdown.tsx @@ -1,12 +1,14 @@ -import type { ButtonProps } from "@canonical/react-components"; +import type { ButtonProps, ValueOf } from "@canonical/react-components"; import TableMenu from "@/app/base/components/TableMenu"; import type { DataTestElement } from "@/app/base/types"; +import type { MachineSidePanelViews } from "@/app/machines/constants"; export type TableAction = { label: string; show?: boolean; type: A; + view?: ValueOf; }; // This allows the "data-testid" attribute to be used for the action links, which @@ -16,7 +18,7 @@ type TableActionsLink = DataTestElement; type Props = { actions: TableAction[]; disabled?: boolean; - onActionClick: (action: A) => void; + onActionClick: (action: A, view?: TableAction["view"]) => void; }; const TableActionsDropdown = ({ @@ -30,7 +32,7 @@ const TableActionsDropdown = ({ links.push({ children: action.label, "data-testid": action.type, - onClick: () => onActionClick(action.type), + onClick: () => onActionClick(action.type, action?.view), }); } return links; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/AddPartition/AddPartitionFields/AddPartitionFields.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/AddPartition/AddPartitionFields/AddPartitionFields.tsx index a989bed260..9bb08fbfce 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/AddPartition/AddPartitionFields/AddPartitionFields.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/AddPartition/AddPartitionFields/AddPartitionFields.tsx @@ -21,7 +21,7 @@ export const AddPartitionFields = ({ return ( - + - + diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.test.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.test.tsx index 252bbbc6a3..7e3395bb68 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.test.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.test.tsx @@ -5,10 +5,12 @@ import configureStore from "redux-mock-store"; import AvailableStorageTable from "./AvailableStorageTable"; +import * as sidePanelHooks from "@/app/base/side-panel-context"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import { actions as machineActions } from "@/app/store/machine"; import { MIN_PARTITION_SIZE } from "@/app/store/machine/constants"; import type { RootState } from "@/app/store/root/types"; -import { DiskTypes, StorageLayout } from "@/app/store/types/enum"; +import { DiskTypes } from "@/app/store/types/enum"; import { controllerDetails as controllerDetailsFactory, controllerState as controllerStateFactory, @@ -37,6 +39,11 @@ const getAvailableDisk = (name = "available-disk") => type: DiskTypes.PHYSICAL, }); +const setSidePanelContent = vi.fn(); +afterEach(() => { + vi.restoreAllMocks(); +}); + it("can show an empty message", () => { const machine = machineDetailsFactory({ disks: [], @@ -251,6 +258,12 @@ describe("performing machine actions", () => { }); it("can open the add partition form if disk can be partitioned", async () => { + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); const disk = getAvailableDisk(); const machine = machineDetailsFactory({ disks: [disk], @@ -264,15 +277,10 @@ describe("performing machine actions", () => { }), }), }); - const store = mockStore(state); - render( - - - - - - - + + renderWithBrowserRouter( + , + { state } ); await userEvent.click(screen.getByRole("button", { name: /Take action/ })); @@ -280,7 +288,9 @@ describe("performing machine actions", () => { screen.getByRole("button", { name: /Add partition/ }) ); - expect(screen.getByLabelText("Add partition form")).toBeInTheDocument(); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ view: MachineSidePanelViews.CREATE_PARTITION }) + ); }); it("can open the edit partition form if partition can be edited", async () => { @@ -301,15 +311,10 @@ describe("performing machine actions", () => { }), }), }); - const store = mockStore(state); - render( - - - - - - - + + renderWithBrowserRouter( + , + { state } ); await userEvent.click(screen.getByRole("button", { name: /Take action/ })); @@ -359,6 +364,12 @@ describe("performing machine actions", () => { }); it("can open the edit disk form if the disk is not a volume group", async () => { + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); const disk = diskFactory({ type: DiskTypes.PHYSICAL }); const machine = machineDetailsFactory({ disks: [disk], @@ -372,15 +383,10 @@ describe("performing machine actions", () => { }), }), }); - const store = mockStore(state); - render( - - - - - - - + + renderWithBrowserRouter( + , + { state } ); await userEvent.click(screen.getByRole("button", { name: /Take action/ })); @@ -388,7 +394,9 @@ describe("performing machine actions", () => { screen.getByRole("button", { name: /Edit physical disk/ }) ); - expect(screen.getByLabelText("Edit disk form")).toBeInTheDocument(); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ view: MachineSidePanelViews.EDIT_DISK }) + ); }); it("can open the create bcache form if the machine has at least one cache set", async () => { @@ -469,52 +477,6 @@ describe("performing machine actions", () => { ).toBeDisabled(); }); - it("can create a cache set from a disk", async () => { - const disk = diskFactory({ - available_size: MIN_PARTITION_SIZE + 1, - partitions: [], - type: DiskTypes.PHYSICAL, - }); - const machine = machineDetailsFactory({ - disks: [disk], - system_id: "abc123", - }); - const state = rootStateFactory({ - machine: machineStateFactory({ - items: [machine], - statuses: machineStatusesFactory({ - abc123: machineStatusFactory(), - }), - }), - }); - const store = mockStore(state); - render( - - - - - - - - ); - - await userEvent.click(screen.getByRole("button", { name: /Take action/ })); - await userEvent.click( - screen.getByRole("button", { name: /Create cache set/ }) - ); - await userEvent.click( - screen.getByRole("button", { name: /Create cache set/ }) - ); - - const expectedAction = machineActions.createCacheSet({ - blockId: disk.id, - systemId: machine.system_id, - }); - expect( - store.getActions().find((action) => action.type === expectedAction.type) - ).toStrictEqual(expectedAction); - }); - it("can create a cache set from a partition", async () => { const partition = partitionFactory({ filesystem: null }); const machine = machineDetailsFactory({ @@ -562,111 +524,6 @@ describe("performing machine actions", () => { ).toStrictEqual(expectedAction); }); - it("can set the boot disk", async () => { - const [nonBootDisk, bootDisk] = [ - diskFactory({ - available_size: MIN_PARTITION_SIZE + 1, - is_boot: false, - type: DiskTypes.PHYSICAL, - }), - diskFactory({ - available_size: MIN_PARTITION_SIZE + 1, - is_boot: true, - type: DiskTypes.PHYSICAL, - }), - ]; - const machine = machineDetailsFactory({ - detected_storage_layout: StorageLayout.BLANK, - disks: [nonBootDisk, bootDisk], - system_id: "abc123", - }); - const state = rootStateFactory({ - machine: machineStateFactory({ - items: [machine], - statuses: machineStatusesFactory({ - abc123: machineStatusFactory(), - }), - }), - }); - const store = mockStore(state); - render( - - - - - - - - ); - - await userEvent.click( - screen.getAllByRole("button", { name: /Take action/ })[0] - ); - await userEvent.click( - screen.getByRole("button", { name: /Set boot disk/ }) - ); - await userEvent.click( - screen.getByRole("button", { name: /Set boot disk/ }) - ); - - const expectedAction = machineActions.setBootDisk({ - blockId: nonBootDisk.id, - systemId: machine.system_id, - }); - expect( - store.getActions().find((action) => action.type === expectedAction.type) - ).toStrictEqual(expectedAction); - }); - - it("can delete a disk", async () => { - const disk = diskFactory({ - available_size: MIN_PARTITION_SIZE + 1, - partitions: [], - type: DiskTypes.PHYSICAL, - }); - const machine = machineDetailsFactory({ - disks: [disk], - system_id: "abc123", - }); - const state = rootStateFactory({ - machine: machineStateFactory({ - items: [machine], - statuses: machineStatusesFactory({ - abc123: machineStatusFactory(), - }), - }), - }); - const store = mockStore(state); - render( - - - - - - - - ); - - await userEvent.click(screen.getByRole("button", { name: /Take action/ })); - await userEvent.click( - screen.getByRole("button", { name: /Remove physical disk/ }) - ); - await userEvent.click( - screen.getByRole("button", { name: /Remove physical disk/ }) - ); - - const expectedAction = machineActions.deleteDisk({ - blockId: disk.id, - systemId: machine.system_id, - }); - expect( - screen.getByText("Are you sure you want to remove this physical disk?") - ).toBeInTheDocument(); - expect( - store.getActions().find((action) => action.type === expectedAction.type) - ).toStrictEqual(expectedAction); - }); - it("can delete a volume group", async () => { const disk = diskFactory({ available_size: MIN_PARTITION_SIZE + 1, diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx index 480eb51174..f29e6ff379 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/AvailableStorageTable.tsx @@ -20,6 +20,7 @@ import ActionConfirm from "@/app/base/components/node/ActionConfirm"; import DiskBootStatus from "@/app/base/components/node/DiskBootStatus"; import DiskNumaNodes from "@/app/base/components/node/DiskNumaNodes"; import DiskTestStatus from "@/app/base/components/node/DiskTestStatus"; +import type { SetSidePanelContent } from "@/app/base/side-panel-context"; import { useSidePanel } from "@/app/base/side-panel-context"; import urls from "@/app/base/urls"; import type { ControllerDetails } from "@/app/store/controller/types"; @@ -131,7 +132,8 @@ const normaliseRowData = ( expanded: Expanded | null, setExpanded: (expanded: Expanded | null) => void, selected: (Disk | Partition)[], - handleRowCheckbox: (storageDevice: Disk | Partition) => void + handleRowCheckbox: (storageDevice: Disk | Partition) => void, + setSidePanelContent: SetSidePanelContent ) => { const rowId = uniqueId(storageDevice); const isExpanded = expanded?.id === rowId && Boolean(expanded?.content); @@ -264,9 +266,21 @@ const normaliseRowData = ( content: ( - setExpanded({ content: action, id: rowId }) - } + onActionClick={(action: StorageDeviceAction, view) => { + if (view) { + setSidePanelContent({ + view, + extras: { + systemId, + disk: isDisk(storageDevice) + ? storageDevice + : undefined, + }, + }); + } else { + setExpanded({ content: action, id: rowId }); + } + }} storageDevice={storageDevice} systemId={systemId} /> @@ -288,7 +302,7 @@ const AvailableStorageTable = ({ const [expanded, setExpanded] = useState(null); const [selected, setSelected] = useState<(Disk | Partition)[]>([]); const isMachine = nodeIsMachine(node); - const { sidePanelContent } = useSidePanel(); + const { sidePanelContent, setSidePanelContent } = useSidePanel(); const closeExpanded = () => setExpanded(null); const handleRowCheckbox = (storageDevice: Disk | Partition) => { @@ -358,7 +372,8 @@ const AvailableStorageTable = ({ expanded, setExpanded, selected, - handleRowCheckbox + handleRowCheckbox, + setSidePanelContent ), expandedContent: isMachine ? (
@@ -504,7 +519,8 @@ const AvailableStorageTable = ({ expanded, setExpanded, selected, - handleRowCheckbox + handleRowCheckbox, + setSidePanelContent ), expandedContent: isMachine ? (
diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.test.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.test.tsx new file mode 100644 index 0000000000..ee7deaebbb --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.test.tsx @@ -0,0 +1,60 @@ +import configureStore from "redux-mock-store"; + +import CreateCacheSet from "./CreateCacheSet"; + +import type { RootState } from "@/app/store/root/types"; +import { + machineDetails as machineDetailsFactory, + machineState as machineStateFactory, + machineStatus as machineStatusFactory, + machineStatuses as machineStatusesFactory, + nodeDisk as diskFactory, + nodePartition as partitionFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +const mockStore = configureStore(); +const disk = diskFactory({ + id: 1, + name: "floppy-disk", + partitions: [partitionFactory(), partitionFactory()], +}); + +const state = rootStateFactory({ + machine: machineStateFactory({ + items: [machineDetailsFactory({ disks: [disk], system_id: "abc123" })], + statuses: machineStatusesFactory({ + abc123: machineStatusFactory(), + }), + }), +}); + +it("should render the form", () => { + renderWithBrowserRouter( + , + { state } + ); + + expect( + screen.getByRole("form", { name: "Create cache set" }) + ).toBeInTheDocument(); +}); + +it("should fire an action to create cache set", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { store } + ); + + await userEvent.click( + screen.getByRole("button", { name: "Create cache set" }) + ); + + expect( + store + .getActions() + .some((action) => action.type === "machine/createCacheSet") + ).toBe(true); +}); diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.tsx new file mode 100644 index 0000000000..00558e3fc7 --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/CreateCacheSet.tsx @@ -0,0 +1,44 @@ +import { useDispatch } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { actions as machineActions } from "@/app/store/machine"; +import type { Machine } from "@/app/store/machine/types"; +import type { Disk } from "@/app/store/types/node"; + +type Props = { + close: () => void; + systemId: Machine["system_id"]; + diskId: Disk["id"]; +}; + +const CreateCacheSet = ({ systemId, diskId, close }: Props) => { + const dispatch = useDispatch(); + return ( + Are you sure you want to create a cache set?} + modelType="cache set" + onCancel={close} + onSaveAnalytics={{ + action: "Create cache set from disk", + category: "Machine storage", + label: "Create cache set", + }} + onSubmit={() => { + dispatch(machineActions.cleanup()); + dispatch( + machineActions.createCacheSet({ + blockId: diskId, + systemId: systemId, + }) + ); + close(); + }} + submitAppearance="positive" + submitLabel="Create cache set" + /> + ); +}; + +export default CreateCacheSet; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/index.ts b/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/index.ts new file mode 100644 index 0000000000..56eb6aa618 --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateCacheSet"; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.test.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.test.tsx new file mode 100644 index 0000000000..9615d35dbf --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.test.tsx @@ -0,0 +1,56 @@ +import configureStore from "redux-mock-store"; + +import DeleteDisk from "./DeleteDisk"; + +import type { RootState } from "@/app/store/root/types"; +import { + machineDetails as machineDetailsFactory, + machineState as machineStateFactory, + machineStatus as machineStatusFactory, + machineStatuses as machineStatusesFactory, + nodeDisk as diskFactory, + nodePartition as partitionFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +const mockStore = configureStore(); +const disk = diskFactory({ + id: 1, + name: "floppy-disk", + partitions: [partitionFactory(), partitionFactory()], +}); + +const state = rootStateFactory({ + machine: machineStateFactory({ + items: [machineDetailsFactory({ disks: [disk], system_id: "abc123" })], + statuses: machineStatusesFactory({ + abc123: machineStatusFactory(), + }), + }), +}); + +it("should render the form", () => { + renderWithBrowserRouter( + , + { state } + ); + + expect(screen.getByRole("form", { name: "Delete disk" })).toBeInTheDocument(); +}); + +it("should fire an action to delete a disk", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { store } + ); + + await userEvent.click( + screen.getByRole("button", { name: "Remove physical disk" }) + ); + + expect( + store.getActions().some((action) => action.type === "machine/deleteDisk") + ).toBe(true); +}); diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.tsx new file mode 100644 index 0000000000..c0c6024b2a --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/DeleteDisk.tsx @@ -0,0 +1,46 @@ +import { useDispatch } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { actions as machineActions } from "@/app/store/machine"; +import type { Machine } from "@/app/store/machine/types"; +import type { Disk } from "@/app/store/types/node"; +import { formatType } from "@/app/store/utils"; + +type Props = { + close: () => void; + systemId: Machine["system_id"]; + disk: Disk; +}; + +const DeleteDisk = ({ systemId, disk, close }: Props) => { + const dispatch = useDispatch(); + const diskType = formatType(disk, true); + return ( + Are you sure you want to remove this {diskType}?} + modelType={diskType} + onCancel={close} + onSaveAnalytics={{ + action: `Delete ${diskType}`, + category: "Machine storage", + label: `Remove ${diskType}`, + }} + onSubmit={() => { + dispatch(machineActions.cleanup()); + dispatch( + machineActions.deleteDisk({ + blockId: disk.id, + systemId: systemId, + }) + ); + close(); + }} + submitAppearance="negative" + submitLabel={`Remove ${diskType}`} + /> + ); +}; + +export default DeleteDisk; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/index.ts b/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/index.ts new file mode 100644 index 0000000000..e563fc2646 --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteDisk"; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/EditDisk/EditDiskFields/EditDiskFields.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/EditDisk/EditDiskFields/EditDiskFields.tsx index d2fa4d8ca0..4f66f37dc6 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/EditDisk/EditDiskFields/EditDiskFields.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/EditDisk/EditDiskFields/EditDiskFields.tsx @@ -15,7 +15,7 @@ type Props = { export const EditDiskFields = ({ disk, systemId }: Props): JSX.Element => { return ( - + { value={formatSize(disk.size)} /> - + {disk.is_boot === false && } diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.test.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.test.tsx new file mode 100644 index 0000000000..350a2bcb14 --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.test.tsx @@ -0,0 +1,56 @@ +import configureStore from "redux-mock-store"; + +import SetBootDisk from "./SetBootDisk"; + +import type { RootState } from "@/app/store/root/types"; +import { + machineDetails as machineDetailsFactory, + machineState as machineStateFactory, + machineStatus as machineStatusFactory, + machineStatuses as machineStatusesFactory, + nodeDisk as diskFactory, + nodePartition as partitionFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +const mockStore = configureStore(); +const disk = diskFactory({ + id: 1, + name: "floppy-disk", + partitions: [partitionFactory(), partitionFactory()], +}); + +const state = rootStateFactory({ + machine: machineStateFactory({ + items: [machineDetailsFactory({ disks: [disk], system_id: "abc123" })], + statuses: machineStatusesFactory({ + abc123: machineStatusFactory(), + }), + }), +}); + +it("should render the form", () => { + renderWithBrowserRouter( + , + { state } + ); + + expect( + screen.getByRole("form", { name: "Set boot disk" }) + ).toBeInTheDocument(); +}); + +it("should fire an action to set boot disk", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { store } + ); + + await userEvent.click(screen.getByRole("button", { name: "Set boot disk" })); + + expect( + store.getActions().some((action) => action.type === "machine/setBootDisk") + ).toBe(true); +}); diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.tsx new file mode 100644 index 0000000000..10555fed36 --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/SetBootDisk.tsx @@ -0,0 +1,44 @@ +import { useDispatch } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { actions as machineActions } from "@/app/store/machine"; +import type { Machine } from "@/app/store/machine/types"; +import type { Disk } from "@/app/store/types/node"; + +type Props = { + close: () => void; + systemId: Machine["system_id"]; + diskId: Disk["id"]; +}; + +const SetBootDisk = ({ systemId, diskId, close }: Props) => { + const dispatch = useDispatch(); + return ( + Are you sure you want to set boot disk?} + modelType="boot disk" + onCancel={close} + onSaveAnalytics={{ + action: "Set boot disk", + category: "Machine storage", + label: "Set boot disk", + }} + onSubmit={() => { + dispatch(machineActions.cleanup()); + dispatch( + machineActions.setBootDisk({ + blockId: diskId, + systemId: systemId, + }) + ); + close(); + }} + submitAppearance="positive" + submitLabel="Set boot disk" + /> + ); +}; + +export default SetBootDisk; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/index.ts b/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/index.ts new file mode 100644 index 0000000000..fc96be3d6b --- /dev/null +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk/index.ts @@ -0,0 +1 @@ +export { default } from "./SetBootDisk"; diff --git a/src/app/base/components/node/StorageTables/AvailableStorageTable/StorageDeviceActions/StorageDeviceActions.tsx b/src/app/base/components/node/StorageTables/AvailableStorageTable/StorageDeviceActions/StorageDeviceActions.tsx index 8354ea709a..6836f4bc09 100644 --- a/src/app/base/components/node/StorageTables/AvailableStorageTable/StorageDeviceActions/StorageDeviceActions.tsx +++ b/src/app/base/components/node/StorageTables/AvailableStorageTable/StorageDeviceActions/StorageDeviceActions.tsx @@ -4,6 +4,7 @@ import { StorageDeviceAction } from "../AvailableStorageTable"; import TableActionsDropdown from "@/app/base/components/TableActionsDropdown"; import type { TableAction } from "@/app/base/components/TableActionsDropdown"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import machineSelectors from "@/app/store/machine/selectors"; import type { MachineDetails } from "@/app/store/machine/types"; import { isMachineDetails } from "@/app/store/machine/utils"; @@ -25,7 +26,10 @@ import { type Props = { disabled: boolean; storageDevice: Disk | Partition; - onActionClick: (rowAction: StorageDeviceAction) => void; + onActionClick: ( + rowAction: StorageDeviceAction, + view?: TableAction["view"] + ) => void; systemId: MachineDetails["system_id"]; }; @@ -52,6 +56,7 @@ const StorageDeviceActions = ({ label: "Add partition...", show: canBePartitioned(storageDevice), type: StorageDeviceAction.CREATE_PARTITION, + view: MachineSidePanelViews.CREATE_PARTITION, }, { label: "Create bcache...", @@ -62,16 +67,19 @@ const StorageDeviceActions = ({ label: "Create cache set...", show: canCreateCacheSet(storageDevice), type: StorageDeviceAction.CREATE_CACHE_SET, + view: MachineSidePanelViews.CREATE_CACHE_SET, }, { label: "Set boot disk...", show: canSetBootDisk(machine.detected_storage_layout, storageDevice), type: StorageDeviceAction.SET_BOOT_DISK, + view: MachineSidePanelViews.SET_BOOT_DISK, }, { label: `Edit ${formatType(storageDevice, true)}...`, show: !isVolumeGroup(storageDevice), type: StorageDeviceAction.EDIT_DISK, + view: MachineSidePanelViews.EDIT_DISK, }, { label: "Remove volume group...", @@ -82,6 +90,7 @@ const StorageDeviceActions = ({ label: `Remove ${formatType(storageDevice, true)}...`, show: canBeDeleted(storageDevice) && !isVolumeGroup(storageDevice), type: StorageDeviceAction.DELETE_DISK, + view: MachineSidePanelViews.DELETE_DISK, }, ]; } diff --git a/src/app/machines/components/MachineForms/MachineForms.tsx b/src/app/machines/components/MachineForms/MachineForms.tsx index 4093b3c2e4..782e5bf8c3 100644 --- a/src/app/machines/components/MachineForms/MachineForms.tsx +++ b/src/app/machines/components/MachineForms/MachineForms.tsx @@ -10,10 +10,15 @@ import AddChassisForm from "./AddChassis/AddChassisForm"; import AddMachineForm from "./AddMachine/AddMachineForm"; import MachineActionFormWrapper from "./MachineActionFormWrapper"; +import AddPartition from "@/app/base/components/node/StorageTables/AvailableStorageTable/AddPartition"; import CreateDatastore from "@/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateDatastore"; import CreateRaid from "@/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateRaid"; import CreateVolumeGroup from "@/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/CreateVolumeGroup"; import UpdateDatastore from "@/app/base/components/node/StorageTables/AvailableStorageTable/BulkActions/UpdateDatastore"; +import CreateCacheSet from "@/app/base/components/node/StorageTables/AvailableStorageTable/CreateCacheSet"; +import DeleteDisk from "@/app/base/components/node/StorageTables/AvailableStorageTable/DeleteDisk"; +import EditDisk from "@/app/base/components/node/StorageTables/AvailableStorageTable/EditDisk"; +import SetBootDisk from "@/app/base/components/node/StorageTables/AvailableStorageTable/SetBootDisk"; import AddSpecialFilesystem from "@/app/base/components/node/StorageTables/FilesystemsTable/AddSpecialFilesystem"; import type { SidePanelContentTypes } from "@/app/base/side-panel-context"; import type { SetSearchFilter } from "@/app/base/types"; @@ -71,6 +76,7 @@ export const MachineForms = ({ const node = extras && "node" in extras ? extras.node : undefined; const linkId = extras && "linkId" in extras ? extras.linkId : undefined; const nicId = extras && "nicId" in extras ? extras.nicId : undefined; + const disk = extras && "disk" in extras ? extras.disk : undefined; switch (sidePanelContent.view) { case MachineSidePanelViews.ADD_CHASSIS: @@ -146,6 +152,16 @@ export const MachineForms = ({ /> ); } + case MachineSidePanelViews.CREATE_CACHE_SET: { + if (!systemId || !disk) return null; + return ( + + ); + } case MachineSidePanelViews.CREATE_DATASTORE: { if (!bulkActionSelected || !systemId) return null; return ( @@ -156,6 +172,16 @@ export const MachineForms = ({ /> ); } + case MachineSidePanelViews.CREATE_PARTITION: { + if (!systemId || !disk) return null; + return ( + + ); + } case MachineSidePanelViews.CREATE_RAID: { if (!bulkActionSelected || !systemId) return null; return ( @@ -176,6 +202,26 @@ export const MachineForms = ({ /> ); } + case MachineSidePanelViews.DELETE_DISK: { + if (!disk || !systemId) return null; + return ( + + ); + } + case MachineSidePanelViews.EDIT_DISK: { + if (!disk || !systemId) return null; + return ( + + ); + } case MachineSidePanelViews.EDIT_PHYSICAL: { if (!systemId || !selected || !setSelected) return null; return ( @@ -224,6 +270,16 @@ export const MachineForms = ({ /> ); } + case MachineSidePanelViews.SET_BOOT_DISK: { + if (!systemId || !disk) return null; + return ( + + ); + } case MachineSidePanelViews.UPDATE_DATASTORE: { if (!bulkActionSelected || !systemId) return null; return ( diff --git a/src/app/machines/constants.ts b/src/app/machines/constants.ts index 7c4dde0e3c..02c211476c 100644 --- a/src/app/machines/constants.ts +++ b/src/app/machines/constants.ts @@ -39,13 +39,18 @@ export const MachineNonActionSidePanelViews = { ADD_SPECIAL_FILESYSTEM: ["machineNonActionForm", "addSpecialFilesystem"], ADD_VLAN: ["machineNonActionForm", "addVlan"], APPLY_STORAGE_LAYOUT: ["machineNonActionForm", "applyStorageLayout"], + CREATE_CACHE_SET: ["machineNonActionForm", "createCacheSet"], CREATE_DATASTORE: ["machineNonActionForm", "createDatastore"], + CREATE_PARTITION: ["machineNonActionForm", "createPartition"], CREATE_RAID: ["machineNonActionForm", "createRaid"], CREATE_VOLUME_GROUP: ["machineNonActionForm", "createVolumeGroup"], + DELETE_DISK: ["machineNonActionForm", "deleteDisk"], + EDIT_DISK: ["machineNonActionForm", "editDisk"], EDIT_PHYSICAL: ["machineNonActionForm", "editPhysical"], MARK_CONNECTED: ["machineNonActionForm", "markConnected"], MARK_DISCONNECTED: ["machineNonActionForm", "markDisconnected"], REMOVE_PHYSICAL: ["machineNonActionForm", "removePhysical"], + SET_BOOT_DISK: ["machineNonActionForm", "setBootDisk"], UPDATE_DATASTORE: ["machineNonActionForm", "updateDatastore"], } as const; diff --git a/src/app/machines/types.ts b/src/app/machines/types.ts index cf8ea5cbfb..b60e7bdc9c 100644 --- a/src/app/machines/types.ts +++ b/src/app/machines/types.ts @@ -87,6 +87,13 @@ export type MachineSidePanelContent = linkId?: NetworkLink["id"]; nicId?: NetworkInterface["id"]; } + > + | SidePanelContent< + ValueOf, + { + systemId?: Machine["system_id"]; + disk?: Disk; + } >; export type MachineSetSidePanelContent = diff --git a/src/app/store/utils/node/base.ts b/src/app/store/utils/node/base.ts index c32730045f..8060d7af60 100644 --- a/src/app/store/utils/node/base.ts +++ b/src/app/store/utils/node/base.ts @@ -222,20 +222,28 @@ export const getSidePanelTitle = ( return "Change storage layout"; case SidePanelViews.CLEAR_ALL_DISCOVERIES[1]: return "Clear all discoveries"; + case SidePanelViews.CREATE_CACHE_SET[1]: + return "Create cache set"; case SidePanelViews.CREATE_DATASTORE[1]: return "Create datastore"; + case SidePanelViews.CREATE_PARTITION[1]: + return "Create partition"; case SidePanelViews.CREATE_RAID[1]: return "Create raid"; case SidePanelViews.CREATE_VOLUME_GROUP[1]: return "Create volume group"; case SidePanelViews.DELETE_DISCOVERY[1]: return "Delete discovery"; + case SidePanelViews.DELETE_DISK[1]: + return "Delete disk"; 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.EDIT_DISK[1]: + return "Edit disk"; case SidePanelViews.EDIT_PHYSICAL[1]: return "Edit physical"; case SidePanelViews.DELETE_IMAGE[1]: @@ -252,6 +260,8 @@ export const getSidePanelTitle = ( return "Remove interface"; case SidePanelViews.REMOVE_PHYSICAL[1]: return "Remove physical"; + case SidePanelViews.SET_BOOT_DISK[1]: + return "Set boot disk"; case SidePanelViews.SET_DEFAULT[1]: return "Set default"; case SidePanelViews.UPDATE_DATASTORE[1]: