From e581abd03e78be42852110de4c007edd10dfcd67 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 3 Feb 2025 09:32:51 +0000 Subject: [PATCH 1/4] web: Button to add a drive to the proposal --- web/src/components/core/MenuHeader.tsx | 47 +++++++++ web/src/components/core/index.ts | 1 + .../storage/AddExistingDeviceMenu.tsx | 98 +++++++++++++++++++ .../components/storage/ConfigEditorMenu.tsx | 1 - web/src/components/storage/DriveEditor.tsx | 36 +------ .../storage/MenuDeviceDescription.tsx | 50 ++++++++++ web/src/components/storage/ProposalPage.tsx | 4 + web/src/queries/storage/config-model.ts | 25 +++++ 8 files changed, 228 insertions(+), 34 deletions(-) create mode 100644 web/src/components/core/MenuHeader.tsx create mode 100644 web/src/components/storage/AddExistingDeviceMenu.tsx create mode 100644 web/src/components/storage/MenuDeviceDescription.tsx diff --git a/web/src/components/core/MenuHeader.tsx b/web/src/components/core/MenuHeader.tsx new file mode 100644 index 0000000000..d3643dca42 --- /dev/null +++ b/web/src/components/core/MenuHeader.tsx @@ -0,0 +1,47 @@ +/* + * Copyright (c) [2023-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Flex, Content } from "@patternfly/react-core"; +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +export type MenuHeaderProps = { title: string; description: React.ReactNode }; + +/** + * Renders the content to be used at a menu entry describing subsequent options. + * @component + * + * @param title - Short sentence describing the functionality. + * @param descriptions - Extra details rendered with a smaller font. + */ +export default function MenuHeader({ title, description }: MenuHeaderProps) { + return ( + + {title} + {description && {description}} + + ); +} diff --git a/web/src/components/core/index.ts b/web/src/components/core/index.ts index d2e7babb03..eaa41ba0ad 100644 --- a/web/src/components/core/index.ts +++ b/web/src/components/core/index.ts @@ -46,3 +46,4 @@ export { default as EmptyState } from "./EmptyState"; export { default as InstallerOptions } from "./InstallerOptions"; export { default as IssuesDrawer } from "./IssuesDrawer"; export { default as Drawer } from "./Drawer"; +export { default as MenuHeader } from "./MenuHeader"; diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx new file mode 100644 index 0000000000..b7e743ec74 --- /dev/null +++ b/web/src/components/storage/AddExistingDeviceMenu.tsx @@ -0,0 +1,98 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { _, n_ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { + Dropdown, + DropdownList, + DropdownItem, + DropdownGroup, + MenuToggleElement, + MenuToggle, + Divider, +} from "@patternfly/react-core"; +import { MenuHeader } from "~/components/core"; +import MenuDeviceDescription from "./MenuDeviceDescription"; +import { useAvailableDevices, useConfigModel, useModel } from "~/queries/storage"; +import { deviceLabel } from "~/components/storage/utils"; + +export default function AddExistingDeviceMenu() { + const [isOpen, setIsOpen] = useState(false); + const toggle = () => setIsOpen(!isOpen); + const allDevices = useAvailableDevices(); + const model = useConfigModel({ suspense: true }); + const modelHook = useModel(); + + const drivesNames = model.drives.map((d) => d.name); + const devices = allDevices.filter((d) => !drivesNames.includes(d.name)); + + const Header = ({ drives }) => { + const desc = sprintf( + n_( + "Extends the installation beyond the currently selected disk", + "Extends the installation beyond the current %d disks", + drives.length, + ), + drives.length, + ); + + return ; + }; + + if (!devices.length) return null; + + return ( + ) => ( + + {_("Use additional disk")} + + )} + > + + }> + + {devices.map((device) => ( + } + onClick={() => modelHook.addDrive(device.name)} + > + {deviceLabel(device)} + + ))} + + + + ); +} diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 10951d4420..1dc674f393 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -56,7 +56,6 @@ export default function ConfigEditorMenu() { )} > - {_("Use additional disk")} {_("Add LVM volume group")} {_("Add MD RAID")} diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 5f2c9456c5..bf3ad4996e 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -33,6 +33,8 @@ import { useDrive, usePartition } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; import { typeDescription, contentDescription } from "~/components/storage/utils/device"; import { Icon } from "../layout"; +import { MenuHeader } from "~/components/core"; +import MenuDeviceDescription from "./MenuDeviceDescription"; import { Card, CardBody, @@ -42,7 +44,6 @@ import { Flex, Label, Split, - Stack, Menu, MenuContainer, MenuContent, @@ -53,7 +54,6 @@ import { MenuToggleProps, MenuToggleElement, MenuGroup, - Content, } from "@patternfly/react-core"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; @@ -72,17 +72,6 @@ export const InlineMenuToggle = React.forwardRef( ), ); -const MenuHeader = ({ title, description }) => ( - - {title} - {description && {description}} - -); - // FIXME: Presentation is quite poor const SpacePolicySelectorIntro = ({ device }) => { const main = _("Choose what to with current content"); @@ -287,25 +276,6 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg = false, onChange } const navigate = useNavigate(); const devices = useAvailableDevices(); - // FIXME: Presentation is quite poor - const DeviceDescription = ({ device }) => { - return ( - - - {typeDescription(device)} - {contentDescription(device)} - - - {device.systems.map((s, i) => ( - - ))} - - - ); - }; - const NewVgOption = () => { if (withNewVg) return ( @@ -334,7 +304,7 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg = false, onChange } key={device.sid} itemId={device.sid} isSelected={isSelected} - description={} + description={} onClick={() => onChange(device.name)} > diff --git a/web/src/components/storage/MenuDeviceDescription.tsx b/web/src/components/storage/MenuDeviceDescription.tsx new file mode 100644 index 0000000000..c39a454327 --- /dev/null +++ b/web/src/components/storage/MenuDeviceDescription.tsx @@ -0,0 +1,50 @@ +/* + * Copyright (c) [2023-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Stack, Split, Label } from "@patternfly/react-core"; +import { typeDescription, contentDescription } from "~/components/storage/utils/device"; +import { StorageDevice } from "~/types/storage"; + +/** + * Renders the content to be used at a menu entry describing a device. + * @component + * + * @param device - Device to represent + */ +export default function MenuDeviceDescription({ device }: { device: StorageDevice }) { + return ( + + + {typeDescription(device)} + {contentDescription(device)} + + + {device.systems.map((s, i) => ( + + ))} + + + ); +} diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 77d0e2d910..7862d5f43b 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -29,6 +29,7 @@ import ProposalResultSection from "./ProposalResultSection"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; import ConfigEditor from "./ConfigEditor"; import ConfigEditorMenu from "./ConfigEditorMenu"; +import AddExistingDeviceMenu from "./AddExistingDeviceMenu"; import { toValidationError } from "~/utils"; import { useIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; @@ -91,6 +92,9 @@ export default function ProposalPage() { actions={ <> + + + diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 198ac9baee..a75aa545e1 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -168,6 +168,15 @@ function switchDrive( return model; } +function addDrive(originalModel: configModel.Config, driveName: string): configModel.Config { + if (findDrive(originalModel, driveName)) return; + + const model = copyModel(originalModel); + model.drives.push({ name: driveName }); + + return model; +} + function setCustomSpacePolicy( originalModel: configModel.Config, driveName: string, @@ -322,3 +331,19 @@ export function useDrive(name: string): DriveHook | undefined { delete: () => mutate(removeDrive(model, name)), }; } + +/** + * Hook for operating on the collections of the model. + */ +export type ModelHook = { + addDrive: (driveName: string) => void; +}; + +export function useModel(): ModelHook { + const model = useConfigModel(); + const { mutate } = useConfigModelMutation(); + + return { + addDrive: (driveName) => mutate(addDrive(model, driveName)), + }; +} From 4502eea197ebdebea2c379058eb47f071028d8d2 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 6 Feb 2025 12:54:05 +0000 Subject: [PATCH 2/4] web: Relax type check (probably wrong at Patternfly) --- web/src/components/storage/AddExistingDeviceMenu.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx index b7e743ec74..285c737f02 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.tsx @@ -80,6 +80,7 @@ export default function AddExistingDeviceMenu() { )} > + {/* @ts-ignore:next-line See https://github.com/patternfly/patternfly/issues/7327 */} }> {devices.map((device) => ( From a2d668d8440987a629e273d105df426845260f4b Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 6 Feb 2025 13:00:14 +0000 Subject: [PATCH 3/4] web: Unit tests for the new component --- .../storage/AddExistingDeviceMenu.test.tsx | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 web/src/components/storage/AddExistingDeviceMenu.test.tsx diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/AddExistingDeviceMenu.test.tsx new file mode 100644 index 0000000000..95d6a7ba68 --- /dev/null +++ b/web/src/components/storage/AddExistingDeviceMenu.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import AddExistingDeviceMenu from "~/components/storage/AddExistingDeviceMenu"; +import { StorageDevice } from "~/types/storage"; +import * as ConfigModel from "~/api/storage/types/config-model"; + +const vda: StorageDevice = { + sid: 59, + type: "disk", + isDrive: true, + description: "", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vda", + size: 1e12, + systems: ["Windows 11", "openSUSE Leap 15.2"], +}; + +const vdb: StorageDevice = { + sid: 60, + type: "disk", + isDrive: true, + description: "", + vendor: "Seagate", + model: "Unknown", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vdb", + size: 1e6, + systems: [], +}; + +const vdaDrive: ConfigModel.Drive = { + name: "/dev/vda", + spacePolicy: "delete", + partitions: [], +}; + +const vdbDrive: ConfigModel.Drive = { + name: "/dev/vdb", + spacePolicy: "delete", + partitions: [], +}; + +const mockUseConfigModelFn = jest.fn(); +const mockAddDriveFn = jest.fn(); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useConfigModel: () => mockUseConfigModelFn(), + useAvailableDevices: () => [vda, vdb], + useModel: () => ({ addDrive: mockAddDriveFn }), +})); + +describe("when there are unused disks", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [] }); + }); + + it("renders the menu", async () => { + plainRender(); + expect(screen.queryByText(/Use additional disk/)).toBeInTheDocument(); + }); + + it("allows users to add a new drive", async () => { + const { user } = plainRender(); + + const button = screen.getByRole("button", { name: /Use additional/ }); + await user.click(button); + const devicesMenu = screen.getByRole("menu"); + const vdaItem = within(devicesMenu).getByRole("menuitem", { name: /vda/ }); + await user.click(vdaItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); +}); + +describe("when there are no more unused disks", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] }); + }); + + it("renders nothing", async () => { + plainRender(); + expect(screen.queryByText(/Use additional disk/)).toBeNull(); + }); +}); From 7803c6b337db2d60114ccc0fa13fca88f0cb556d Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 6 Feb 2025 13:19:56 +0000 Subject: [PATCH 4/4] web: use ts-expect-error instead of ts-ignore --- web/src/components/storage/AddExistingDeviceMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx index 285c737f02..e4c267efa4 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.tsx @@ -80,7 +80,7 @@ export default function AddExistingDeviceMenu() { )} > - {/* @ts-ignore:next-line See https://github.com/patternfly/patternfly/issues/7327 */} + {/* @ts-expect-error See https://github.com/patternfly/patternfly/issues/7327 */} }> {devices.map((device) => (