diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb index 0bc8b47f3e..68d5f3d73f 100644 --- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2025] SUSE LLC +# Copyright (c) [2025-2026] SUSE LLC # # All Rights Reserved. # @@ -53,7 +53,7 @@ def lvm_vg_size # # @return [Array] def lvm_vg_pvs - storage_device.lvm_pvs.map(&:sid) + storage_device.lvm_pvs.map(&:plain_blk_device).map(&:sid) end end end diff --git a/web/src/components/core/Annotation.test.tsx b/web/src/components/core/Annotation.test.tsx index 67436501f0..75c3bab92e 100644 --- a/web/src/components/core/Annotation.test.tsx +++ b/web/src/components/core/Annotation.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -48,4 +48,9 @@ describe("Annotation", () => { const content = screen.getByText("Configured for installation only"); expect(content.tagName).toBe("STRONG"); }); + + it("renders nothing when children is empty", () => { + const { container } = plainRender({undefined}); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/web/src/components/core/Annotation.tsx b/web/src/components/core/Annotation.tsx index a5f27e4370..46b5a5f7ef 100644 --- a/web/src/components/core/Annotation.tsx +++ b/web/src/components/core/Annotation.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -45,6 +45,8 @@ type AnnotationProps = React.PropsWithChildren<{ * ``` */ export default function Annotation({ icon = "emergency", children }: AnnotationProps) { + if (!children) return null; + return ( {children} diff --git a/web/src/components/core/Popup.test.tsx b/web/src/components/core/Popup.test.tsx index 8e11362f70..953eab3a74 100644 --- a/web/src/components/core/Popup.test.tsx +++ b/web/src/components/core/Popup.test.tsx @@ -172,6 +172,13 @@ describe("Popup.SecondaryAction", () => { const button = screen.queryByRole("button", { name: "Do something" }); expect(button.classList.contains("pf-m-secondary")).toBe(true); }); + + it("renders a 'link' button when asLink is set", async () => { + installerRender(Do something); + + const button = screen.queryByRole("button", { name: "Do something" }); + expect(button.classList.contains("pf-m-link")).toBe(true); + }); }); describe("Popup.AncillaryAction", () => { @@ -234,4 +241,14 @@ describe("Popup.Cancel", () => { expect(button.classList.contains("pf-m-secondary")).toBe(true); }); }); + + describe("when asLink is set", () => { + it("renders a 'link' button", async () => { + installerRender(); + + const button = screen.queryByRole("button", { name: "Cancel" }); + expect(button).not.toBeNull(); + expect(button.classList.contains("pf-m-link")).toBe(true); + }); + }); }); diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 91ed9906e2..b5839b1900 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -36,7 +36,7 @@ import { fork } from "radashi"; import { _, TranslatedString } from "~/i18n"; type ButtonWithoutVariantProps = Omit; -type PredefinedAction = React.PropsWithChildren; +type PredefinedAction = React.PropsWithChildren; export type PopupProps = { /** The dialog title */ title?: ModalHeaderProps["title"]; @@ -122,8 +122,8 @@ const Confirm = ({ children = _("Confirm"), ...actionProps }: PredefinedAction) * Dismiss * */ -const SecondaryAction = ({ children, ...actionProps }: PredefinedAction) => ( - +const SecondaryAction = ({ children, asLink, ...actionProps }: PredefinedAction) => ( + {children} ); diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index 08e3c5a3a8..b1fd3004cb 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -60,6 +60,7 @@ import MoreVert from "@bolderIcons/more_vert.svg?component"; import NetworkWifi from "@icons/network_wifi.svg?component"; import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; +import NotificationsActive from "@icons/notifications_active.svg?component"; import Report from "@icons/report.svg?component"; import RestartAlt from "@icons/restart_alt.svg?component"; import SearchOff from "@icons/search_off.svg?component"; @@ -109,6 +110,7 @@ const icons = { network_wifi: NetworkWifi, network_wifi_1_bar: NetworkWifi1Bar, network_wifi_3_bar: NetworkWifi3Bar, + notifications_ative: NotificationsActive, report: Report, restart_alt: RestartAlt, search_off: SearchOff, diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index eeb604e971..38289a28e2 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -70,8 +70,8 @@ export default function ConfigEditor() { <> {/* FIXME add arial label */} - {volumeGroups.map((vg, i) => { - return ; + {volumeGroups.map((_, i) => { + return ; })} {mdRaids.map((_, i) => ( diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx index b7e7c929ba..d724e8ebd0 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -53,6 +53,22 @@ const vdb: Storage.Device = { }, }; +const md0: Storage.Device = { + sid: 61, + class: "mdRaid", + name: "/dev/md0", + description: "MD RAID 0", + md: { level: "raid1", devices: [59, 60] }, + block: { + start: 0, + size: 2e12, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + const vdaDrive: ConfigModel.Drive = { name: "/dev/vda", spacePolicy: "delete", @@ -68,10 +84,13 @@ const vdbDrive: ConfigModel.Drive = { const mockAddDrive = jest.fn(); const mockAddReusedMdRaid = jest.fn(); const mockUseModel = jest.fn(); +const mockUseAvailableDevices = jest.fn(); jest.mock("~/hooks/model/system/storage", () => ({ ...jest.requireActual("~/hooks/model/system/storage"), - useAvailableDevices: () => [vda, vdb], + useAvailableDevices: () => mockUseAvailableDevices(), + useDevices: () => [], + useFlattenDevices: () => [], })); jest.mock("~/hooks/model/storage/config-model", () => ({ @@ -79,11 +98,13 @@ jest.mock("~/hooks/model/storage/config-model", () => ({ useConfigModel: () => mockUseModel(), useAddDrive: () => mockAddDrive, useAddMdRaid: () => mockAddReusedMdRaid, + useAddVolumeGroup: () => jest.fn(), })); describe("ConfigureDeviceMenu", () => { beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [], mdRaids: [] }); + mockUseModel.mockReturnValue({ drives: [], mdRaids: [], volumeGroups: [] }); + mockUseAvailableDevices.mockReturnValue([vda, vdb]); }); it("renders an initially closed menu ", async () => { @@ -113,18 +134,36 @@ describe("ConfigureDeviceMenu", () => { const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" }); await user.click(disksMenuItem); const dialog = screen.getByRole("dialog", { name: /Select a disk/ }); - const confirmButton = screen.getByRole("button", { name: "Confirm" }); - const vdaItemRow = within(dialog).getByRole("row", { name: /\/dev\/vda/ }); + const confirmButton = screen.getByRole("button", { name: /Add/ }); + const vdaItemRow = within(dialog).getByRole("row", { name: /vda/ }); const vdaItemRadio = within(vdaItemRow).getByRole("radio"); await user.click(vdaItemRadio); await user.click(confirmButton); expect(mockAddDrive).toHaveBeenCalledWith({ name: "/dev/vda", spacePolicy: "keep" }); }); + + it("shows intro text in the device selector", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + await user.click(screen.getByRole("menuitem", { name: "Add device menu" })); + within(screen.getByRole("dialog")).getByText("Start configuring a basic installation"); + }); + + it("allows canceling the device selector without adding any device", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + await user.click(screen.getByRole("menuitem", { name: "Add device menu" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(mockAddDrive).not.toHaveBeenCalled(); + }); }); describe("but some disks are already configured", () => { beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [vdaDrive], mdRaids: [] }); + mockUseModel.mockReturnValue({ drives: [vdaDrive], mdRaids: [], volumeGroups: [] }); }); it("allows users to add a new drive to an unused disk", async () => { @@ -134,11 +173,11 @@ describe("ConfigureDeviceMenu", () => { const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" }); await user.click(disksMenuItem); const dialog = screen.getByRole("dialog", { name: /Select another disk/ }); - const confirmButton = screen.getByRole("button", { name: "Confirm" }); - expect(screen.queryByRole("row", { name: /vda$/ })).toBeNull(); - const vdaItemRow = within(dialog).getByRole("row", { name: /\/dev\/vdb/ }); - const vdaItemRadio = within(vdaItemRow).getByRole("radio"); - await user.click(vdaItemRadio); + const confirmButton = screen.getByRole("button", { name: /Add/ }); + expect(screen.queryByRole("row", { name: /vda/ })).toBeNull(); + const vdbItemRow = within(dialog).getByRole("row", { name: /vdb/ }); + const vdbItemRadio = within(vdbItemRow).getByRole("radio"); + await user.click(vdbItemRadio); await user.click(confirmButton); expect(mockAddDrive).toHaveBeenCalledWith({ name: "/dev/vdb", spacePolicy: "keep" }); }); @@ -147,7 +186,7 @@ describe("ConfigureDeviceMenu", () => { describe("when there are no more unused disks", () => { beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [vdaDrive, vdbDrive], mdRaids: [] }); + mockUseModel.mockReturnValue({ drives: [vdaDrive, vdbDrive], mdRaids: [], volumeGroups: [] }); }); it("renders the disks menu as disabled with an informative label", async () => { @@ -156,6 +195,24 @@ describe("ConfigureDeviceMenu", () => { await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" }); expect(disksMenuItem).toBeDisabled(); + within(disksMenuItem).getByText("Already using all available disks"); + }); + }); + + describe("when there are MD RAID devices available", () => { + beforeEach(() => { + mockUseAvailableDevices.mockReturnValue([vda, md0]); + }); + + it("allows adding an MD RAID device", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button", { name: /More devices/ }); + await user.click(toggler); + await user.click(screen.getByRole("menuitem", { name: "Add device menu" })); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("tab", { name: "RAID" })); + await user.click(screen.getByRole("button", { name: /Add/ })); + expect(mockAddReusedMdRaid).toHaveBeenCalledWith({ name: "/dev/md0", spacePolicy: "keep" }); }); }); }); diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index 5594776e56..3e4b68867c 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -25,42 +25,50 @@ import { useNavigate } from "react-router"; import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton"; import { Divider, Flex, MenuItemProps } from "@patternfly/react-core"; import { useAvailableDevices } from "~/hooks/model/system/storage"; -import { useConfigModel, useAddDrive, useAddMdRaid } from "~/hooks/model/storage/config-model"; +import { + useConfigModel, + useAddDrive, + useAddMdRaid, + useAddVolumeGroup, +} from "~/hooks/model/storage/config-model"; import { STORAGE as PATHS } from "~/routes/paths"; import { sprintf } from "sprintf-js"; import { _, n_ } from "~/i18n"; import DeviceSelectorModal from "./DeviceSelectorModal"; -import { isDrive } from "~/model/storage/device"; +import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device"; +import configModel from "~/model/storage/config-model"; import { Icon } from "../layout"; import type { Storage } from "~/model/system"; type AddDeviceMenuItemProps = { /** Whether some of the available devices is an MD RAID */ withRaids: boolean; + /** Whether some of the available devices is an LVM volume group */ + withLvm: boolean; /** Available devices to be chosen */ devices: Storage.Device[]; - /** The total amount of drives and RAIDs already configured */ + /** The total amount of devices (drives, RAIDs and VGs) already configured */ usedCount: number; } & MenuItemProps; -const AddDeviceTitle = ({ withRaids, usedCount }) => { - if (withRaids) { - if (usedCount === 0) return _("Select a device to define partitions or to mount"); - return _("Select another device to define partitions or to mount"); +const AddDeviceTitle = ({ withRaids, withLvm, usedCount }) => { + if (withRaids || withLvm) { + if (usedCount === 0) return _("Select an existing device"); + return _("Select another existing device"); } - if (usedCount === 0) return _("Select a disk to define partitions or to mount"); - return _("Select another disk to define partitions or to mount"); + if (usedCount === 0) return _("Select a disk"); + return _("Select another disk"); }; -const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => { +const AddDeviceDescription = ({ withRaids, withLvm, usedCount, isDisabled = false }) => { if (isDisabled) { - if (withRaids) return _("Already using all available devices"); + if (withRaids || withLvm) return _("Already using all available devices"); return _("Already using all available disks"); } if (usedCount) { - if (withRaids) + if (withRaids || withLvm) return sprintf( n_( "Extend the installation beyond the currently selected device", @@ -88,6 +96,7 @@ const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => { */ const AddDeviceMenuItem = ({ withRaids, + withLvm, usedCount, devices, onClick, @@ -101,13 +110,14 @@ const AddDeviceMenuItem = ({ description={ } onClick={onClick} > - + ); @@ -126,17 +136,25 @@ export default function ConfigureDeviceMenu(): React.ReactNode { const config = useConfigModel(); const addDrive = useAddDrive(); - const addReusedMdRaid = useAddMdRaid(); + const addMdRaid = useAddMdRaid(); + const addVolumeGroup = useAddVolumeGroup(); const allDevices = useAvailableDevices(); - const usedDevicesNames = config.drives.concat(config.mdRaids).map((d) => d.name); + const usedDevicesNames = configModel.devices(config).map((d) => d.name); const usedDevicesCount = usedDevicesNames.length; - const devices = allDevices.filter((d) => !usedDevicesNames.includes(d.name)); - const withRaids = !!allDevices.filter((d) => !isDrive(d)).length; + const availableDevices = allDevices.filter((d) => !usedDevicesNames.includes(d.name)); + const disks = availableDevices.filter(isDrive); + const mdRaids = availableDevices.filter(isMd); + const volumeGroups = availableDevices.filter(isVolumeGroup); + const withRaids = !!allDevices.filter((d) => isMd(d)).length; + const withLvm = !!allDevices.filter((d) => isVolumeGroup(d)).length; const addDevice = (device: Storage.Device) => { - const hook = isDrive(device) ? addDrive : addReusedMdRaid; - hook({ name: device.name, spacePolicy: "keep" }); + if (isDrive(device)) addDrive({ name: device.name, spacePolicy: "keep" }); + + if (isMd(device)) addMdRaid({ name: device.name, spacePolicy: "keep" }); + + if (isVolumeGroup(device)) addVolumeGroup({ name: device.name, spacePolicy: "keep" }, false); }; const lvmDescription = allDevices.length @@ -157,8 +175,9 @@ export default function ConfigureDeviceMenu(): React.ReactNode { , , @@ -172,15 +191,27 @@ export default function ConfigureDeviceMenu(): React.ReactNode { ]} > - {/** TODO: choose one, "add" or "add_circle", and remove the other at Icon.tsx */} {_("More devices")} {deviceSelectorOpen && ( } - description={} + disks={disks} + mdRaids={mdRaids} + volumeGroups={volumeGroups} + title={ + + } + intro={ + + } + disksIntro={_("Choose a disk to define partitions or to mount")} + mdRaidsIntro={_("Choose a RAID device to define partitions or to mount")} + volumeGroupsIntro={_("Choose a volume group to define logical volumes")} onCancel={closeDeviceSelector} onConfirm={([device]) => { addDevice(device); diff --git a/web/src/components/storage/DeviceContent.test.tsx b/web/src/components/storage/DeviceContent.test.tsx new file mode 100644 index 0000000000..006c77c816 --- /dev/null +++ b/web/src/components/storage/DeviceContent.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2026] 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 } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import type { Storage } from "~/model/system"; +import DeviceContent from "./DeviceContent"; + +const disk: Storage.Device = { + sid: 1, + class: "drive", + name: "/dev/sda", + description: "ACME Disk", + drive: { + model: "ACME", + vendor: "", + bus: "SATA", + busId: "", + transport: "", + driver: [], + info: { dellBoss: false, sdCard: false }, + }, + block: { + start: 0, + size: 512e9, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + +describe("DeviceContent", () => { + it("renders the content description", () => { + plainRender(); + screen.getByText("ACME Disk"); + }); + + it("renders installed system names as labels", () => { + const device: Storage.Device = { + ...disk, + block: { ...disk.block, systems: ["Windows 11", "openSUSE Leap 15.6"] }, + }; + plainRender(); + screen.getByText("Windows 11"); + screen.getByText("openSUSE Leap 15.6"); + }); + + it("renders filesystem labels", () => { + const device: Storage.Device = { + ...disk, + filesystem: { sid: 100, type: "ext4", label: "root" }, + }; + plainRender(); + screen.getByText("root"); + }); +}); diff --git a/web/src/components/storage/DeviceContent.tsx b/web/src/components/storage/DeviceContent.tsx new file mode 100644 index 0000000000..42e7429b31 --- /dev/null +++ b/web/src/components/storage/DeviceContent.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) [2026] 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, Label } from "@patternfly/react-core"; +import { deviceSystems } from "~/model/storage/device"; +import { contentDescription, filesystemLabels } from "~/components/storage/utils/device"; + +import type { Storage } from "~/model/system"; + +/** + * Displays a summary of a storage device's current content: a textual + * description (e.g. partition table info or filesystem type), installed + * system names, and filesystem labels. + */ +export default function DeviceContent({ device }: { device: Storage.Device }) { + return ( + + {contentDescription(device)} + {deviceSystems(device).map((s, i) => ( + + ))} + {filesystemLabels(device).map((s, i) => ( + + ))} + + ); +} diff --git a/web/src/components/storage/DeviceEditorContent.test.tsx b/web/src/components/storage/DeviceEditorContent.test.tsx index 74a60ed9b8..2e14d11f97 100644 --- a/web/src/components/storage/DeviceEditorContent.test.tsx +++ b/web/src/components/storage/DeviceEditorContent.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -42,6 +42,8 @@ const mockConfigModel = jest.fn(); jest.mock("~/hooks/model/storage/config-model", () => ({ useConfigModel: () => mockConfigModel(), + usePartitionable: (_collection: string, index: number) => + mockConfigModel()?.drives[index] ?? null, })); const driveWithPartitions: ConfigModel.Drive = { diff --git a/web/src/components/storage/DeviceEditorContent.tsx b/web/src/components/storage/DeviceEditorContent.tsx index 483aeeac9a..e55d1075b7 100644 --- a/web/src/components/storage/DeviceEditorContent.tsx +++ b/web/src/components/storage/DeviceEditorContent.tsx @@ -25,7 +25,7 @@ import UnusedMenu from "~/components/storage/UnusedMenu"; import FilesystemMenu from "~/components/storage/FilesystemMenu"; import PartitionsSection from "~/components/storage/PartitionsSection"; import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; -import { useConfigModel } from "~/hooks/model/storage/config-model"; +import { useConfigModel, usePartitionable } from "~/hooks/model/storage/config-model"; import configModel from "~/model/storage/config-model"; type DeviceEditorContentProps = { @@ -38,16 +38,18 @@ export default function DeviceEditorContent({ index, }: DeviceEditorContentProps): React.ReactNode { const config = useConfigModel(); - const device = config[collection][index]; - const isUsed = configModel.partitionable.isUsed(config, device.name); + const deviceConfig = usePartitionable(collection, index); + const isUsed = + configModel.partitionable.isUsed(config, deviceConfig.name) || + configModel.boot.hasDevice(config, deviceConfig.name); if (!isUsed) return ; return ( <> - {device.filesystem && } - {!device.filesystem && } - {!device.filesystem && } + {deviceConfig.filesystem && } + {!deviceConfig.filesystem && } + {!deviceConfig.filesystem && } ); } diff --git a/web/src/components/storage/DeviceSelectorModal.test.tsx b/web/src/components/storage/DeviceSelectorModal.test.tsx index 2e83d9b329..68651e0ad7 100644 --- a/web/src/components/storage/DeviceSelectorModal.test.tsx +++ b/web/src/components/storage/DeviceSelectorModal.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -22,10 +22,16 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { getColumnValues, plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import type { Storage } from "~/model/system"; import DeviceSelectorModal from "./DeviceSelectorModal"; +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useDevices: () => [], + useFlattenDevices: () => [], +})); + const sda: Storage.Device = { sid: 59, class: "drive", @@ -38,10 +44,7 @@ const sda: Storage.Device = { busId: "", transport: "usb", driver: ["ahci", "mmcblk"], - info: { - dellBoss: false, - sdCard: true, - }, + info: { dellBoss: false, sdCard: true }, }, block: { start: 1, @@ -58,6 +61,14 @@ const sdb: Storage.Device = { class: "drive", name: "/dev/sdb", description: "SDB drive", + drive: { + model: "Samsung Evo 8 Pro", + vendor: "Samsung", + bus: "IDE", + busId: "", + transport: "", + info: { dellBoss: false, sdCard: false }, + }, block: { start: 1, size: 2048, @@ -66,27 +77,41 @@ const sdb: Storage.Device = { systems: [], shrinking: { supported: false }, }, - drive: { - model: "Samsung Evo 8 Pro", - vendor: "Samsung", - bus: "IDE", - busId: "", - transport: "", - info: { - dellBoss: false, - sdCard: false, - }, +}; + +const md0: Storage.Device = { + sid: 70, + class: "mdRaid", + name: "/dev/md0", + description: "MD RAID 0", + md: { level: "raid1", devices: [1, 2] }, + block: { + start: 0, + size: 10240, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, }, }; +const vg0: Storage.Device = { + sid: 80, + class: "volumeGroup", + name: "/dev/vg0", + description: "Volume group 0", + volumeGroup: { size: 51200, physicalVolumes: [1, 2] }, + logicalVolumes: [], +}; + const onCancelMock = jest.fn(); const onConfirmMock = jest.fn(); describe("DeviceSelectorModal", () => { - it("renders a modal dialog with a table for selecting a device", () => { - plainRender( + it("renders a modal dialog", () => { + installerRender( { screen.getByRole("dialog", { name: "Select a device" }); }); - it("renders type, name, content, and filesystems columns", () => { - plainRender( - , + it("shows Disks, RAID, and LVM tabs", () => { + installerRender( + , ); - const table = screen.getByRole("grid"); - within(table).getByRole("columnheader", { name: "Device" }); - within(table).getByRole("columnheader", { name: "Size" }); - within(table).getByRole("columnheader", { name: "Description" }); - within(table).getByRole("columnheader", { name: "Current content" }); + screen.getByRole("tab", { name: "Disks" }); + screen.getByRole("tab", { name: "RAID" }); + screen.getByRole("tab", { name: "LVM" }); }); - it.todo("renders type, name, content, and filesystems of each device"); - it.todo("renders corresponding control (radio or checkbox) as checked for given selected device"); + it("shows a description hinting at the tabbed layout", () => { + installerRender( + , + ); + screen.getByText(/Use the tabs to browse/); + }); - it("allows sorting by device name", async () => { - const { user } = plainRender( + it("renders the intro content above the tabs", () => { + installerRender( Introductory text

} + title="Select" onCancel={onCancelMock} onConfirm={onConfirmMock} />, ); + screen.getByText("Introductory text"); + }); - const table = screen.getByRole("grid"); - const sortByDeviceButton = within(table).getByRole("button", { name: "Device" }); + describe("initial tab", () => { + it("opens the Disks tab by default", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "Disks" })).toHaveAttribute("aria-selected", "true"); + }); - expect(getColumnValues(table, "Device")).toEqual(["/dev/sda", "/dev/sdb"]); + it("opens the tab matching initialTab", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true"); + }); - await user.click(sortByDeviceButton); + it("opens the tab containing the selected device", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "LVM" })).toHaveAttribute("aria-selected", "true"); + }); - expect(getColumnValues(table, "Device")).toEqual(["/dev/sdb", "/dev/sda"]); + it("opens the tab of the auto-selected device when no device is given", () => { + installerRender( + , + ); + expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true"); + }); }); - it("allows sorting by device size", async () => { - const { user } = plainRender( - , - ); + describe("sideEffectsAlert", () => { + it("shows the disks alert in the footer when the selection differs from the given device", async () => { + const { user } = installerRender( + Disk selection note

} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + screen.getByText("Disk selection note"); + }); + + it("shows the RAID alert in the footer when the selection differs from the given device", async () => { + const { user } = installerRender( + RAID selection note

} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + const mdRow = screen.getByRole("row", { name: /md0/ }); + await user.click(within(mdRow).getByRole("radio")); + screen.getByText("RAID selection note"); + }); + + it("shows the LVM alert in the footer when the selection differs from the given device", async () => { + const { user } = installerRender( + LVM selection note

} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + const vgRow = screen.getByRole("row", { name: /vg0/ }); + await user.click(within(vgRow).getByRole("radio")); + screen.getByText("LVM selection note"); + }); + + it("does not show the alert when the selection matches the given device", () => { + installerRender( + Disk selection note

} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + expect(screen.queryByText("Disk selection note")).toBeNull(); + }); + }); - const table = screen.getByRole("grid"); - const sortBySizeButton = within(table).getByRole("button", { name: "Size" }); + describe("empty states", () => { + it("shows an empty state in the Disks tab when no disks are given", () => { + installerRender( + , + ); + screen.getByText("No disks found"); + }); - // By default, table is sorted by device name. Switch sorting to size in asc direction - await user.click(sortBySizeButton); + it("shows an empty state in the RAID tab when no RAID devices are given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByText("No RAID devices found"); + }); - expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]); + it("shows an empty state in the LVM tab when no volume groups are given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("No LVM volume groups found"); + }); - // Now keep sorting by size, but in desc direction - await user.click(sortBySizeButton); + it("shows the create link in the empty LVM state when newVolumeGroupLinkText is given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByRole("link", { name: "Define a new LVM" }); + }); - expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]); + it("does not show a create link in the empty LVM state when newVolumeGroupLinkText is not given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + expect(screen.queryByRole("link", { name: /create/i })).toBeNull(); + }); + + it("does not show a create link in the empty RAID state", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + expect(screen.queryByRole("link", { name: /create/i })).toBeNull(); + }); }); - it("triggers onCancel callback when users selects `Cancel` action", async () => { - const { user } = plainRender( - , - ); + describe("LVM tab with volume groups", () => { + it("shows the create link when newVolumeGroupLinkText is given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByRole("link", { name: "Define a new LVM" }); + }); - const cancelAction = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelAction); - expect(onCancelMock).toHaveBeenCalled(); + it("does not show a create link when newVolumeGroupLinkText is not given", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + expect(screen.queryByRole("link", { name: /create/i })).toBeNull(); + }); }); - it("triggers `onCancel` callback when users selects `Cancel` action", async () => { - const { user } = plainRender( - , - ); + describe("autoSelectOnTabChange", () => { + it("auto-selects the first device of the new tab by default", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("button", { name: /Add.*md0/ }); + }); + + it("clears the selection when switching to an empty tab by default", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("button", { name: "Change" }); + }); - const cancelAction = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelAction); - expect(onCancelMock).toHaveBeenCalled(); + it("keeps the current selection when false", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("button", { name: /Add.*sda/ }); + }); }); - it("triggers `onConfirm` callback with selected devices when users selects `Confirm` action", async () => { - const { user } = plainRender( - , - ); + describe("actions", () => { + it("triggers onCancel when user selects Cancel", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancelMock).toHaveBeenCalled(); + }); + + it("shows 'Add' when there is no prior device", () => { + installerRender( + , + ); + screen.getByRole("button", { name: /Add/ }); + }); + + it("shows 'Keep' when the selection matches the given device", () => { + installerRender( + , + ); + screen.getByRole("button", { name: /Keep/ }); + }); + + it("shows 'Change to' when the selection differs from the given device", async () => { + const { user } = installerRender( + , + ); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + screen.getByRole("button", { name: /Change to/ }); + }); + + it("shows a 'Select a device' hint when no devices are available", () => { + installerRender( + , + ); + screen.getByText("Select a device"); + }); - const sdbRow = screen.getByRole("row", { name: /\/dev\/sdb/ }); - const sdbRadio = within(sdbRow).getByRole("radio"); - await user.click(sdbRadio); - const confirmAction = screen.getByRole("button", { name: "Confirm" }); - await user.click(confirmAction); - expect(onConfirmMock).toHaveBeenCalledWith([sdb]); + it("triggers onConfirm with the selected device when the user confirms", async () => { + const { user } = installerRender( + , + ); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + await user.click(screen.getByRole("button", { name: /Change to/ })); + expect(onConfirmMock).toHaveBeenCalledWith([sdb]); + }); }); }); diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index 9639ad8fc7..188ed34c28 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -20,134 +20,367 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; -import { ButtonProps, Flex, Label } from "@patternfly/react-core"; -import Popup, { PopupProps } from "~/components/core/Popup"; -import SelectableDataTable, { - SortedBy, - SelectableDataTableProps, -} from "~/components/core/SelectableDataTable"; +import React, { useId, useState } from "react"; +import { first } from "radashi"; +import { sprintf } from "sprintf-js"; import { - typeDescription, - contentDescription, - filesystemLabels, -} from "~/components/storage/utils/device"; -import { deviceSize } from "~/components/storage/utils"; -import { sortCollection } from "~/utils"; + ButtonProps, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + Flex, + HelperText, + HelperTextItem, + PageSection, + Stack, + Tab, + Tabs, +} from "@patternfly/react-core"; +import Annotation from "~/components/core/Annotation"; +import Link from "~/components/core/Link"; +import NestedContent from "~/components/core/NestedContent"; +import Popup from "~/components/core/Popup"; +import SubtleContent from "~/components/core/SubtleContent"; +import DrivesTable from "~/components/storage/DrivesTable"; +import MdRaidsTable from "~/components/storage/MdRaidsTable"; +import VolumeGroupsTable from "~/components/storage/VolumeGroupsTable"; +import { STORAGE } from "~/routes/paths"; +import { deviceLabel } from "~/components/storage/utils"; import { _ } from "~/i18n"; -import { deviceSystems } from "~/model/storage/device"; + +import type { PopupProps } from "~/components/core/Popup"; import type { Storage } from "~/model/system"; -type DeviceSelectorProps = { - devices: Storage.Device[]; - selectedDevices?: Storage.Device[]; - onSelectionChange: SelectableDataTableProps["onSelectionChange"]; - selectionMode?: SelectableDataTableProps["selectionMode"]; -}; +/** Identifies which tab is active in {@link DeviceSelectorModal}. */ +export type TabKey = "disks" | "mdRaids" | "volumeGroups"; -const size = (device: Storage.Device) => { - return deviceSize(device.block.size); +/** Props for {@link DeviceSelectorModal}. */ +export type DeviceSelectorModalProps = Omit & { + /** General information shown at the top of the modal, above the tabs. */ + intro?: React.ReactNode; + /** Tab to open initially. Takes precedence over the tab derived from {@link selected}. */ + initialTab?: TabKey; + /** Currently selected device. Determines the initial tab and initial selection. */ + selected?: Storage.Device; + /** Available disks. */ + disks?: Storage.Device[]; + /** Available software RAID devices. */ + mdRaids?: Storage.Device[]; + /** Available LVM volume groups. */ + volumeGroups?: Storage.Device[]; + /** Side effects of selecting a disk. Only shown when the selection differs from {@link selected}. */ + disksSideEffects?: React.ReactNode; + /** Side effects of selecting a RAID device. Only shown when the selection differs from {@link selected}. */ + mdRaidsSideEffects?: React.ReactNode; + /** Side effects of selecting a volume group. Only shown when the selection differs from {@link selected}. */ + volumeGroupsSideEffects?: React.ReactNode; + /** General information at the top of the Disks tab, if there is any disk. */ + disksIntro?: React.ReactNode; + /** General information at the top of the RAID tab, if there is any MD RAID. */ + mdRaidsIntro?: React.ReactNode; + /** General information at the top of the LVM tab, if there is any volume group. */ + volumeGroupsIntro?: React.ReactNode; + /** Title of the 'empty state' displayed when there are no LVMs to select from. */ + volumeGroupsEmptyTitle?: string; + /** + * Label for the "create a new volume group" link in the LVM tab. + * When set, the link is shown with this text. When not set, no link is shown. + */ + newVolumeGroupLinkText?: string; + /** + * Whether switching tabs auto-selects the first device of the new tab, + * or clears the selection when the tab is empty. Defaults to `true`. + */ + autoSelectOnTabChange?: boolean; + /** Called with the new selection when the user confirms. */ + onConfirm: (selection: Storage.Device[]) => void; + /** Called when the user cancels. */ + onCancel: ButtonProps["onClick"]; }; -const description = (device: Storage.Device) => { - const model = device.drive?.model; - if (model && model.length) return model; +const TABS: Record = { disks: 0, mdRaids: 1, volumeGroups: 2 }; - return typeDescription(device); -}; +/** Empty state shown in a tab when no devices of that type are available. */ +const NoDevicesFound = ({ + title, + body, + action, +}: { + title: string; + body: string; + action?: React.ReactNode; +}) => ( + + {body} + {action && ( + + {action} + + )} + +); -const details = (device: Storage.Device) => { +/** + * Subtle contextual sentence with an inline link embedded via bracket notation. + * The link position and text are extracted from `sentence` using `[text]` + * markers. + */ +const TabIntro = ({ sentence, linkTo }: { sentence: string; linkTo?: string }) => { + const [before, linkText, after] = sentence.split(/[[\]]/); return ( - - {contentDescription(device)} - {deviceSystems(device).map((s, i) => ( - - ))} - {filesystemLabels(device).map((s, i) => ( - - ))} - + + {before} + + {linkText} + + {after} + ); }; -// TODO: document -const DeviceSelector = ({ - devices, - selectedDevices, - onSelectionChange, - selectionMode = "single", -}: DeviceSelectorProps) => { - const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" }); - - const columns = [ - { name: _("Device"), value: (device: Storage.Device) => device.name, sortingKey: "name" }, - { - name: _("Size"), - value: size, - sortingKey: (d: Storage.Device) => d.block.size, - pfTdProps: { style: { width: "10ch" } }, - }, - { name: _("Description"), value: description }, - { name: _("Current content"), value: details }, - ]; - - // Sorting - const sortingKey = columns[sortedBy.index].sortingKey; - const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey); +/** + * Wrapper for a tab's scrollable content area. Renders `children` when given, + * or falls back to {@link NoDevicesFound} built from the `empty*` props. + */ +const TabContent = ({ + emptyTitle, + emptyBody, + emptyAction, + intro, + children, +}: { + emptyTitle: string; + emptyBody: string; + emptyAction?: React.ReactNode; + intro?: React.ReactNode; + children?: React.ReactNode; +}) => ( + + + {children ? ( + <> + {intro} + {children} + + ) : ( + + )} + + +); - return ( - <> - - - ); -}; +/** + * Returns the tab index to activate when the modal opens. + * + * Resolution order: + * 1. Explicit `initialTab` key. + * 2. Tab that contains `selected`. + * 3. First tab (index 0). + */ +function getInitialTabIndex( + initialTab?: TabKey, + selected?: Storage.Device, + deviceLists?: Storage.Device[][], +): number { + if (initialTab) return TABS[initialTab]; -type DeviceSelectorModalProps = Omit & { - selected?: Storage.Device; - devices: Storage.Device[]; - onConfirm: (selection: Storage.Device[]) => void; - onCancel: ButtonProps["onClick"]; -}; + if (selected && deviceLists) { + const index = deviceLists.findIndex((list) => list.some((d) => d.sid === selected.sid)); + return index !== -1 ? index : 0; + } + return 0; +} + +/** + * Modal for selecting a storage device across three categories: disks, + * software RAID devices, and LVM volume groups. + * + * The confirm button label reflects the state of the selection: + * + * - "Add X" when there is no prior device and one is selected, + * - "Keep X" when the selection matches {@link + * DeviceSelectorModalProps.selected}, + * - "Change to X" when a different device is picked, + * - "Add" or "Change" when no device is selected (e.g. after switching to an + * empty tab). + * + * An optional side-effects alert is displayed near the confirm button when the + * user switches to a different device. Both the alert and the "Select a device" + * hint are live regions linked to the confirm button via `aria-describedby` so + * assistive technologies announce changes. + */ export default function DeviceSelectorModal({ - selected = undefined, + selected: previousDevice, + initialTab, onConfirm, onCancel, - devices, + intro, + disks = [], + mdRaids = [], + volumeGroups = [], + disksSideEffects, + mdRaidsSideEffects, + volumeGroupsSideEffects, + disksIntro, + mdRaidsIntro, + volumeGroupsIntro, + volumeGroupsEmptyTitle, + newVolumeGroupLinkText, + autoSelectOnTabChange = true, ...popupProps }: DeviceSelectorModalProps): React.ReactNode { - // FIXME: improve initial selection handling + const confirmHintId = useId(); + const initialDevice = previousDevice ?? first([...disks, ...mdRaids, ...volumeGroups]); const [selectedDevices, setSelectedDevices] = useState( - selected ? [selected] : [devices[0]], + initialDevice ? [initialDevice] : [], ); + const [activeTab, setActiveTab] = useState(() => + getInitialTabIndex(initialTab, initialDevice, [disks, mdRaids, volumeGroups]), + ); + const tabLists = [disks, mdRaids, volumeGroups]; + + const currentDevice = selectedDevices[0]; + const deviceSideEffectsAlert = + currentDevice && + [ + { list: disks, alert: disksSideEffects }, + { list: mdRaids, alert: mdRaidsSideEffects }, + { list: volumeGroups, alert: volumeGroupsSideEffects }, + ].find(({ list }) => list.some((d) => d.sid === currentDevice.sid))?.alert; + + const deviceInInitialTab = + currentDevice && tabLists[activeTab].some((d) => d.sid === currentDevice.sid); + + const onTabClick = (_, tabIndex: number) => { + setActiveTab(tabIndex); + if (autoSelectOnTabChange) { + const device = first(tabLists[tabIndex]); + setSelectedDevices(device ? [device] : []); + } + }; - const onAccept = () => { - selectedDevices !== Array(selected) && onConfirm(selectedDevices); + const confirmLabel = (): string => { + if (!currentDevice) return previousDevice ? _("Change") : _("Add"); + // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)" + if (!previousDevice) return sprintf(_("Add %s"), deviceLabel(currentDevice)); + // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)" + if (currentDevice.sid === previousDevice.sid) + return sprintf(_("Keep %s"), deviceLabel(currentDevice)); + // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)" + return sprintf(_("Change to %s"), deviceLabel(currentDevice)); }; return ( - - + + + {intro} + + + + + {disks.length > 0 && ( + + )} + + + + + {mdRaids.length > 0 && ( + + )} + + + + {newVolumeGroupLinkText} + ) + } + > + {volumeGroups.length > 0 && ( + <> + {newVolumeGroupLinkText && ( + + )} + + + )} + + + + + - - + + {!currentDevice && ( + + {_("Select a device")} + + )} + {currentDevice && currentDevice.sid !== previousDevice?.sid && deviceSideEffectsAlert && ( + + + {deviceSideEffectsAlert} + + + )} + + onConfirm(selectedDevices)} + isDisabled={!currentDevice} + aria-describedby={confirmHintId} + > + {confirmLabel()} + + + + ); diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index e04b7ea246..7cc42fd47c 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -43,7 +43,8 @@ jest.mock("~/hooks/model/storage/config-model", () => ({ useAddDriveFromMdRaid: jest.fn(), useAddMdRaidFromDrive: jest.fn(), useDeleteDrive: () => mockDeleteDrive, - useAddVolumeGroupFromPartitionable: () => mockAddVolumeGroupFromPartitionable, + useConvertPartitionableToVolumeGroup: () => mockAddVolumeGroupFromPartitionable, + useConvertDevice: () => jest.fn(), })); const mockSystemDevice = jest.fn(); diff --git a/web/src/components/storage/DrivesTable.test.tsx b/web/src/components/storage/DrivesTable.test.tsx new file mode 100644 index 0000000000..ad2fa0b507 --- /dev/null +++ b/web/src/components/storage/DrivesTable.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright (c) [2026] 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 { getColumnValues, plainRender } from "~/test-utils"; +import type { Storage } from "~/model/system"; +import DrivesTable from "./DrivesTable"; + +const sda: Storage.Device = { + sid: 59, + class: "drive", + name: "/dev/sda", + description: "SDA drive", + drive: { + model: "Micron 1100 SATA", + vendor: "Micron", + bus: "SATA", + busId: "", + transport: "sata", + driver: [], + info: { dellBoss: false, sdCard: false }, + }, + block: { + start: 0, + size: 1024, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + +const sdb: Storage.Device = { + sid: 62, + class: "drive", + name: "/dev/sdb", + description: "SDB drive", + drive: { + model: "Samsung Evo 8 Pro", + vendor: "Samsung", + bus: "USB", + busId: "", + transport: "usb", + driver: [], + info: { dellBoss: false, sdCard: false }, + }, + block: { + start: 0, + size: 2048, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, +}; + +const onSelectionChangeMock = jest.fn(); + +describe("DrivesTable", () => { + it("renders Device, Size, Description, and Current content columns", () => { + plainRender(); + const table = screen.getByRole("grid"); + within(table).getByRole("columnheader", { name: "Device" }); + within(table).getByRole("columnheader", { name: "Size" }); + within(table).getByRole("columnheader", { name: "Description" }); + within(table).getByRole("columnheader", { name: "Current content" }); + }); + + it("renders a row per device", () => { + plainRender(); + screen.getByRole("row", { name: /sda/ }); + screen.getByRole("row", { name: /sdb/ }); + }); + + it("allows sorting by device name", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sortButton = within(table).getByRole("button", { name: "Device" }); + + expect(getColumnValues(table, "Device")).toEqual(["sda", "sdb"]); + + await user.click(sortButton); + + expect(getColumnValues(table, "Device")).toEqual(["sdb", "sda"]); + }); + + it("allows sorting by size", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sortButton = within(table).getByRole("button", { name: "Size" }); + + await user.click(sortButton); + expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]); + + await user.click(sortButton); + expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]); + }); + + it("calls onSelectionChange when a device is selected", async () => { + const { user } = plainRender( + , + ); + + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + await user.click(within(sdbRow).getByRole("radio")); + expect(onSelectionChangeMock).toHaveBeenCalledWith([sdb]); + }); +}); diff --git a/web/src/components/storage/DrivesTable.tsx b/web/src/components/storage/DrivesTable.tsx new file mode 100644 index 0000000000..99d6bd6e70 --- /dev/null +++ b/web/src/components/storage/DrivesTable.tsx @@ -0,0 +1,101 @@ +/* + * Copyright (c) [2026] 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 SelectableDataTable from "~/components/core/SelectableDataTable"; +import DeviceContent from "~/components/storage/DeviceContent"; +import { deviceBaseName, deviceSize } from "~/components/storage/utils"; +import { typeDescription } from "~/components/storage/utils/device"; +import { sortCollection } from "~/utils"; +import { _ } from "~/i18n"; + +import type { Storage } from "~/model/system"; +import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable"; + +/** Props for {@link DrivesTable}. */ +type DrivesTableProps = { + /** Available drives. */ + devices: Storage.Device[]; + /** Currently selected drives. */ + selectedDevices?: Storage.Device[]; + /** Called when the selection changes. */ + onSelectionChange: SelectableDataTableProps["onSelectionChange"]; + /** Selection mode. Defaults to `"single"`. */ + selectionMode?: SelectableDataTableProps["selectionMode"]; +}; + +const size = (device: Storage.Device) => { + const bytes = device.volumeGroup?.size || device.block?.size || 0; + return deviceSize(bytes); +}; + +const description = (device: Storage.Device) => { + const model = device.drive?.model; + if (model && model.length) return model; + + return typeDescription(device); +}; + +/** + * Table for selecting among available drives. + */ +export default function DrivesTable({ + devices, + selectedDevices, + onSelectionChange, + selectionMode = "single", +}: DrivesTableProps) { + const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" }); + + const columns = [ + { + name: _("Device"), + value: (device: Storage.Device) => deviceBaseName(device), + sortingKey: "name", + pfTdProps: { style: { width: "15ch" } }, + }, + { + name: _("Size"), + value: size, + sortingKey: (d: Storage.Device) => d.block.size, + pfTdProps: { style: { width: "10ch" } }, + }, + { name: _("Description"), value: description }, + { name: _("Current content"), value: (d: Storage.Device) => }, + ]; + + const sortingKey = columns[sortedBy.index].sortingKey; + const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey); + + return ( + + ); +} diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index 697d476f49..ee742c3b9e 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -20,16 +20,11 @@ * find current contact information at www.suse.com. */ -/** - * @fixme This code was done in a hurry for including LVM managent in SLE16 beta3. It must be - * completely refactored. There are a lot of duplications with PartitionPage. Both PartitionPage - * and LogicalVolumePage should be adapted to share as much functionality as possible. - */ - -import React, { useCallback, useEffect, useId, useMemo, useState } from "react"; -import { useParams, useNavigate } from "react-router"; +import React, { useState } from "react"; +import { useParams, useNavigate, useLocation } from "react-router"; import { ActionGroup, + Divider, Flex, FlexItem, Form, @@ -37,44 +32,50 @@ import { FormHelperText, HelperText, HelperTextItem, + Label, SelectGroup, SelectList, SelectOption, SelectOptionProps, + Split, + SplitItem, Stack, - StackItem, TextInput, } from "@patternfly/react-core"; import { Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; import AutoSizeText from "~/components/storage/AutoSizeText"; -import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils"; +import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; +import ResourceNotFound from "~/components/core/ResourceNotFound"; import configModel from "~/model/storage/config-model"; +import { useVolumeTemplate, useDevice } from "~/hooks/model/system/storage"; import { - useSolvedConfigModel, useConfigModel, + useSolvedConfigModel, useMissingMountPaths, - useVolumeGroup, + useVolumeGroup as useConfigModelVolumeGroup, useAddLogicalVolume, useEditLogicalVolume, } from "~/hooks/model/storage/config-model"; -import { useVolumeTemplate } from "~/hooks/model/system/storage"; +import { deviceSize, deviceLabel, filesystemLabel, parseToBytes } from "~/components/storage/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; import { unique } from "radashi"; import { compact } from "~/utils"; -import { sprintf } from "sprintf-js"; -import { _ } from "~/i18n"; -import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; -import type { ConfigModel, Data } from "~/model/storage/config-model"; +import type { ConfigModel } from "~/model/storage/config-model"; import type { Storage as System } from "~/model/system"; const NO_VALUE = ""; +const NEW_LOGICAL_VOLUME = "new"; +const REUSE_FILESYSTEM = "reuse"; type SizeOptionValue = "" | SizeMode; type FormValue = { mountPoint: string; name: string; + target: string; filesystem: string; filesystemLabel: string; sizeOption: SizeOptionValue; @@ -93,7 +94,26 @@ type ErrorsHandler = { getVisibleError: (id: string) => Error | undefined; }; -function toData(value: FormValue): Data.LogicalVolume { +function configuredLogicalVolumes( + volumeGroupConfig: ConfigModel.VolumeGroup, +): ConfigModel.LogicalVolume[] { + if (volumeGroupConfig.spacePolicy === "custom") + return volumeGroupConfig.logicalVolumes.filter( + (l) => + !configModel.volume.isNew(l) && + (configModel.volume.isUsed(l) || configModel.volume.isUsedBySpacePolicy(l)), + ); + + return volumeGroupConfig.logicalVolumes.filter(configModel.volume.isReused); +} + +function createLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume { + const name = (): string | undefined => { + if (value.target === NO_VALUE || value.target === NEW_LOGICAL_VOLUME) return undefined; + + return value.target; + }; + const filesystemType = (): ConfigModel.FilesystemType | undefined => { if (value.filesystem === NO_VALUE) return undefined; @@ -107,11 +127,14 @@ function toData(value: FormValue): Data.LogicalVolume { return value.filesystem as ConfigModel.FilesystemType; }; - const filesystem = (): Data.Filesystem | undefined => { + const filesystem = (): ConfigModel.Filesystem | undefined => { + if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true }; + const type = filesystemType(); if (type === undefined) return undefined; return { + default: false, type, label: value.filesystemLabel, }; @@ -131,26 +154,32 @@ function toData(value: FormValue): Data.LogicalVolume { return { mountPath: value.mountPoint, lvName: value.name, + name: name(), filesystem: filesystem(), size: size(), }; } -function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue { - const mountPoint = (): string => logicalVolume.mountPath || NO_VALUE; +function createFormValue(logicalVolumeConfig: ConfigModel.LogicalVolume): FormValue { + const mountPoint = (): string => logicalVolumeConfig.mountPath || NO_VALUE; + + const target = (): string => logicalVolumeConfig.name || NEW_LOGICAL_VOLUME; const filesystem = (): string => { - const fs = logicalVolume.filesystem; - if (!fs.type) return NO_VALUE; + const fsConfig = logicalVolumeConfig.filesystem; + if (fsConfig.reuse) return REUSE_FILESYSTEM; + if (!fsConfig.type) return NO_VALUE; - return fs.type; + return fsConfig.type; }; - const filesystemLabel = (): string => logicalVolume.filesystem?.label || NO_VALUE; + const filesystemLabel = (): string => logicalVolumeConfig.filesystem?.label || NO_VALUE; const sizeOption = (): SizeOptionValue => { - const size = logicalVolume.size; - if (!size || size.default) return "auto"; + const reuse = logicalVolumeConfig.name !== undefined; + const sizeConfig = logicalVolumeConfig.size; + if (reuse) return NO_VALUE; + if (!sizeConfig || sizeConfig.default) return "auto"; return "custom"; }; @@ -160,24 +189,49 @@ function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue { return { mountPoint: mountPoint(), - name: logicalVolume.lvName, + name: logicalVolumeConfig.lvName, + target: target(), filesystem: filesystem(), filesystemLabel: filesystemLabel(), sizeOption: sizeOption(), - minSize: size(logicalVolume.size?.min), - maxSize: size(logicalVolume.size?.max), + minSize: size(logicalVolumeConfig.size?.min), + maxSize: size(logicalVolumeConfig.size?.max), }; } +function useVolumeGroupConfig(): ConfigModel.VolumeGroup | null { + const { id: index } = useParams(); + + return useConfigModelVolumeGroup(Number(index)) ?? null; +} + +function useVolumeGroup(): System.Device { + const volumeGroupConfig = useVolumeGroupConfig(); + return useDevice(volumeGroupConfig.name); +} + +function useLogicalVolume(target: string): System.Device | null { + const volumeGroup = useVolumeGroup(); + + if (target === NEW_LOGICAL_VOLUME) return null; + + const logicalVolumes = volumeGroup.logicalVolumes || []; + return logicalVolumes.find((p: System.Device) => p.name === target); +} + +function useLogicalVolumeFilesystem(target: string): string | null { + const logicalVolume = useLogicalVolume(target); + return logicalVolume?.filesystem?.type || null; +} + function useDefaultFilesystem(mountPoint: string): string { const volume = useVolumeTemplate(mountPoint); return volume.fsType; } -function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null { - const { id: vgName, logicalVolumeId: mountPath } = useParams(); - const volumeGroup = useVolumeGroup(vgName); - +function useInitialLogicalVolumeConfig(): ConfigModel.LogicalVolume | null { + const { logicalVolumeId: mountPath } = useParams(); + const volumeGroup = useVolumeGroupConfig(); if (!volumeGroup || !mountPath) return null; const logicalVolume = volumeGroup.logicalVolumes.find((l) => l.mountPath === mountPath); @@ -185,23 +239,41 @@ function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null { } function useInitialFormValue(): FormValue | null { - const logicalVolume = useInitialLogicalVolume(); - const value = useMemo(() => (logicalVolume ? toFormValue(logicalVolume) : null), [logicalVolume]); + const logicalVolumeConfig = useInitialLogicalVolumeConfig(); + + const value = React.useMemo( + () => (logicalVolumeConfig ? createFormValue(logicalVolumeConfig) : null), + [logicalVolumeConfig], + ); + return value; } /** Unused predefined mount points. Includes the currently used mount point when editing. */ function useUnusedMountPoints(): string[] { - const missingMountPaths = useMissingMountPaths(); - const initialLogicalVolume = useInitialLogicalVolume(); - return compact([initialLogicalVolume?.mountPath, ...missingMountPaths]); + const unusedMountPaths = useMissingMountPaths(); + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); + return compact([initialLogicalVolumeConfig?.mountPath, ...unusedMountPaths]); +} + +/** Unused logical volumes. Includes the currently used logical volume when editing (if any). */ +function useUnusedLogicalVolumes(): System.Device[] { + const volumeGroup = useVolumeGroup(); + const allLogicalVolumes = volumeGroup.logicalVolumes || []; + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); + const volumeGroupConfig = useVolumeGroupConfig(); + const configuredNames = configuredLogicalVolumes(volumeGroupConfig) + .filter((l) => l.name !== initialLogicalVolumeConfig?.name) + .map((l) => l.name); + + return allLogicalVolumes.filter((l) => !configuredNames.includes(l.name)); } function useUsableFilesystems(mountPoint: string): string[] { const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); - const usableFilesystems = useMemo(() => { + const usableFilesystems = React.useMemo(() => { const volumeFilesystems = (): string[] => { return volume.outline.fsTypes; }; @@ -215,7 +287,7 @@ function useUsableFilesystems(mountPoint: string): string[] { function useMountPointError(value: FormValue): Error | undefined { const config = useConfigModel(); const mountPoints = config ? configModel.usedMountPaths(config) : []; - const initialLogicalVolume = useInitialLogicalVolume(); + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); const mountPoint = value.mountPoint; if (mountPoint === NO_VALUE) { @@ -235,7 +307,7 @@ function useMountPointError(value: FormValue): Error | undefined { } // Exclude itself when editing - const initialMountPoint = initialLogicalVolume?.mountPath; + const initialMountPoint = initialLogicalVolumeConfig?.mountPath; if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) { return { id: "mountPoint", @@ -246,7 +318,7 @@ function useMountPointError(value: FormValue): Error | undefined { } function checkLogicalVolumeName(value: FormValue): Error | undefined { - if (value.name?.length) return; + if (value.target !== NEW_LOGICAL_VOLUME || value.name?.length) return; return { id: "logicalVolumeName", @@ -255,7 +327,7 @@ function checkLogicalVolumeName(value: FormValue): Error | undefined { }; } -function checkSize(value: FormValue): Error | undefined { +function checkSizeError(value: FormValue): Error | undefined { if (value.sizeOption !== "custom") return; const min = value.minSize; @@ -268,7 +340,7 @@ function checkSize(value: FormValue): Error | undefined { }; } - const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/; + const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])$/; const validMin = regexp.test(min); const validMax = max ? regexp.test(max) : true; @@ -285,7 +357,7 @@ function checkSize(value: FormValue): Error | undefined { if (validMin) { return { id: "customSize", - message: _("The maximum must be a number optionally followed by a unit like GiB or GB"), + message: _("The maximum must be a number followed by a unit like GiB or GB"), isVisible: true, }; } @@ -293,14 +365,14 @@ function checkSize(value: FormValue): Error | undefined { if (validMax) { return { id: "customSize", - message: _("The minimum must be a number optionally followed by a unit like GiB or GB"), + message: _("The minimum must be a number followed by a unit like GiB or GB"), isVisible: true, }; } return { id: "customSize", - message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"), + message: _("Size limits must be numbers followed by a unit like GiB or GB"), isVisible: true, }; } @@ -308,7 +380,7 @@ function checkSize(value: FormValue): Error | undefined { function useErrors(value: FormValue): ErrorsHandler { const mountPointError = useMountPointError(value); const nameError = checkLogicalVolumeName(value); - const sizeError = checkSize(value); + const sizeError = checkSizeError(value); const errors = compact([mountPointError, nameError, sizeError]); const getError = (id: string): Error | undefined => errors.find((e) => e.id === id); @@ -321,24 +393,36 @@ function useErrors(value: FormValue): ErrorsHandler { return { errors, getError, getVisibleError }; } -function useSolvedModel(value: FormValue): ConfigModel.Config | null { - const { id: vgName, logicalVolumeId: mountPath } = useParams(); +function useSolvedConfig(value: FormValue): ConfigModel.Config | null { + const { id: index } = useParams(); + const volumeGroupConfig = useVolumeGroupConfig(); const config = useConfigModel(); - const { getError } = useErrors(value); - const mountPointError = getError("mountPoint"); - const data = toData(value); + const { errors } = useErrors(value); + const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig(); + const logicalVolumeConfig = createLogicalVolumeConfig(value); + logicalVolumeConfig.size = undefined; // Avoid recalculating the solved model because changes in label. - if (data.filesystem) data.filesystem.label = undefined; + if (logicalVolumeConfig.filesystem) logicalVolumeConfig.filesystem.label = undefined; // Avoid recalculating the solved model because changes in name. - data.lvName = undefined; + logicalVolumeConfig.lvName = undefined; let sparseModel: ConfigModel.Config | undefined; - if (data.filesystem && !mountPointError) { - if (mountPath) { - sparseModel = configModel.logicalVolume.edit(config, vgName, mountPath, data); + if ( + volumeGroupConfig && + !errors.length && + value.target === NEW_LOGICAL_VOLUME && + value.filesystem !== NO_VALUE + ) { + if (initialLogicalVolumeConfig) { + sparseModel = configModel.logicalVolume.edit( + config, + Number(index), + initialLogicalVolumeConfig.mountPath, + logicalVolumeConfig, + ); } else { - sparseModel = configModel.logicalVolume.add(config, vgName, data); + sparseModel = configModel.logicalVolume.add(config, Number(index), logicalVolumeConfig); } } @@ -346,11 +430,17 @@ function useSolvedModel(value: FormValue): ConfigModel.Config | null { return solvedModel; } -function useSolvedLogicalVolume(value: FormValue): ConfigModel.LogicalVolume | undefined { - const { id: vgName } = useParams(); - const config = useSolvedModel(value); - const volumeGroup = config?.volumeGroups?.find((v) => v.vgName === vgName); - return volumeGroup?.logicalVolumes?.find((l) => l.mountPath === value.mountPoint); +function useSolvedLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume | undefined { + const volumeGroupConfig = useVolumeGroupConfig(); + const solvedConfig = useSolvedConfig(value); + if (!solvedConfig) return; + + const solvedVolumeGroupConfig = configModel.volumeGroup.findByName( + solvedConfig, + volumeGroupConfig.vgName, + ); + + return configModel.device.findVolumeByMountPath(solvedVolumeGroupConfig, value.mountPoint); } function useSolvedSizes(value: FormValue): SizeRange { @@ -362,45 +452,123 @@ function useSolvedSizes(value: FormValue): SizeRange { maxSize: NO_VALUE, }; - const logicalVolume = useSolvedLogicalVolume(valueWithoutSizes); + const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(valueWithoutSizes); - const solvedSizes = useMemo(() => { - const min = logicalVolume?.size?.min; - const max = logicalVolume?.size?.max; + const solvedSizes = React.useMemo(() => { + const min = solvedLogicalVolumeConfig?.size?.min; + const max = solvedLogicalVolumeConfig?.size?.max; return { min: min ? deviceSize(min) : NO_VALUE, max: max ? deviceSize(max) : NO_VALUE, }; - }, [logicalVolume]); + }, [solvedLogicalVolumeConfig]); return solvedSizes; } function useAutoRefreshFilesystem(handler, value: FormValue) { - const { mountPoint } = value; + const { mountPoint, target } = value; const defaultFilesystem = useDefaultFilesystem(mountPoint); + const usableFilesystems = useUsableFilesystems(mountPoint); + const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target); - useEffect(() => { + React.useEffect(() => { // Reset filesystem if there is no mount point yet. if (mountPoint === NO_VALUE) handler(NO_VALUE); // Select default filesystem for the mount point. - if (mountPoint !== NO_VALUE) handler(defaultFilesystem); - }, [handler, mountPoint, defaultFilesystem]); + if (mountPoint !== NO_VALUE && target === NEW_LOGICAL_VOLUME) handler(defaultFilesystem); + // Select default filesystem for the mount point if the logical volume has no filesystem. + if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && !logicalVolumeFilesystem) + handler(defaultFilesystem); + // Reuse the filesystem from the logical volume if possible. + if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && logicalVolumeFilesystem) { + const reuse = usableFilesystems.includes(logicalVolumeFilesystem); + handler(reuse ? REUSE_FILESYSTEM : defaultFilesystem); + } + }, [handler, mountPoint, target, defaultFilesystem, usableFilesystems, logicalVolumeFilesystem]); } function useAutoRefreshSize(handler, value: FormValue) { + const target = value.target; const solvedSizes = useSolvedSizes(value); - useEffect(() => { - handler("auto", solvedSizes.min, solvedSizes.max); - }, [handler, solvedSizes]); + React.useEffect(() => { + const sizeOption = target === NEW_LOGICAL_VOLUME ? "auto" : ""; + handler(sizeOption, solvedSizes.min, solvedSizes.max); + }, [handler, target, solvedSizes]); } function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] { return mountPoints.map((p) => ({ value: p, children: p })); } +type TargetOptionLabelProps = { + value: string; +}; + +function TargetOptionLabel({ value }: TargetOptionLabelProps): React.ReactNode { + const device = useVolumeGroup(); + const logicalVolume = useLogicalVolume(value); + + if (value === NEW_LOGICAL_VOLUME) { + // TRANSLATORS: %s is a disk name with its size (eg. "sda, 10 GiB" + return sprintf(_("As a new logical volume on %s"), deviceLabel(device, true)); + } else { + return sprintf(_("Using logical volume %s"), deviceLabel(logicalVolume, true)); + } +} + +type LogicalVolumeDescriptionProps = { + logicalVolume: System.Device; +}; + +function LogicalVolumeDescription({ + logicalVolume, +}: LogicalVolumeDescriptionProps): React.ReactNode { + const label = logicalVolume.filesystem?.label; + + return ( + + {logicalVolume.description} + {label && ( + + + + )} + + ); +} + +function TargetOptions(): React.ReactNode { + const logicalVolumes = useUnusedLogicalVolumes(); + + return ( + + + + + + + {logicalVolumes.map((logicalVolume, index) => ( + } + > + {deviceLabel(logicalVolume)} + + ))} + {logicalVolumes.length === 0 && ( + {_("There are not usable logical volumes")} + )} + + + ); +} + type LogicalVolumeNameProps = { id?: string; value: FormValue; @@ -444,37 +612,58 @@ function LogicalVolumeName({ type FilesystemOptionLabelProps = { value: string; + target: string; volume: System.Volume; }; -function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode { +function FilesystemOptionLabel({ value, target }: FilesystemOptionLabelProps): React.ReactNode { + const logicalVolume = useLogicalVolume(target); + const filesystem = logicalVolume?.filesystem?.type; + if (value === NO_VALUE) return _("Waiting for a mount point"); + // TRANSLATORS: %s is a filesystem type, like Btrfs + if (value === REUSE_FILESYSTEM && filesystem) + return sprintf(_("Current %s"), filesystemLabel(filesystem)); + return filesystemLabel(value); } type FilesystemOptionsProps = { mountPoint: string; + target: string; }; -function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { +function FilesystemOptions({ mountPoint, target }: FilesystemOptionsProps): React.ReactNode { + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useUsableFilesystems(mountPoint); - const volume = useVolumeTemplate(mountPoint); - - const defaultOptText = - mountPoint !== NO_VALUE && volume.mountPath - ? sprintf(_("Default file system for %s"), mountPoint) - : _("Default file system for generic logical volumes"); + const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target); + const canReuse = logicalVolumeFilesystem && usableFilesystems.includes(logicalVolumeFilesystem); - const formatText = _("Format logical volume as"); + const defaultOptText = volume.mountPath + ? sprintf(_("Default file system for %s"), mountPoint) + : _("Default file system for generic logical volume"); + const formatText = logicalVolumeFilesystem + ? _("Destroy current data and format logical volume as") + : _("Format logical volume as"); return ( {mountPoint === NO_VALUE && ( - + )} + {mountPoint !== NO_VALUE && canReuse && ( + + + + )} + {mountPoint !== NO_VALUE && canReuse && usableFilesystems.length && } {mountPoint !== NO_VALUE && ( {usableFilesystems.map((fsType, index) => ( @@ -483,7 +672,7 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN value={fsType} description={fsType === defaultFilesystem && defaultOptText} > - + ))} @@ -496,6 +685,7 @@ type FilesystemSelectProps = { id?: string; value: string; mountPoint: string; + target: string; onChange: SelectProps["onChange"]; }; @@ -503,6 +693,7 @@ function FilesystemSelect({ id, value, mountPoint, + target, onChange, }: FilesystemSelectProps): React.ReactNode { const volume = useVolumeTemplate(mountPoint); @@ -512,11 +703,11 @@ function FilesystemSelect({ ); } @@ -546,46 +737,63 @@ type AutoSizeInfoProps = { function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { const volume = useVolumeTemplate(value.mountPoint); - const logicalVolume = useSolvedLogicalVolume(value); - const size = logicalVolume?.size; + const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(value); + const size = solvedLogicalVolumeConfig?.size; if (!size) return; return ( - + ); } -export default function LogicalVolumePage() { +const LogicalVolumeForm = () => { + const { id: index } = useParams(); const navigate = useNavigate(); - const headingId = useId(); - const { id: vgName } = useParams(); - const addLogicalVolume = useAddLogicalVolume(); - const editLogicalVolume = useEditLogicalVolume(); + const location = useLocation(); const [mountPoint, setMountPoint] = useState(NO_VALUE); const [name, setName] = useState(NO_VALUE); + const [target, setTarget] = useState(NEW_LOGICAL_VOLUME); const [filesystem, setFilesystem] = useState(NO_VALUE); const [filesystemLabel, setFilesystemLabel] = useState(NO_VALUE); const [sizeOption, setSizeOption] = useState(NO_VALUE); const [minSize, setMinSize] = useState(NO_VALUE); const [maxSize, setMaxSize] = useState(NO_VALUE); - // Filesystem and size selectors should not be auto refreshed before the user interacts with the - // mount point selector. + // Filesystem and size selectors should not be auto refreshed before the user interacts with other + // selectors like the mount point or the target selectors. const [autoRefreshFilesystem, setAutoRefreshFilesystem] = useState(false); const [autoRefreshSize, setAutoRefreshSize] = useState(false); const initialValue = useInitialFormValue(); - const value = { mountPoint, name, filesystem, filesystemLabel, sizeOption, minSize, maxSize }; + const value = { + mountPoint, + name, + target, + filesystem, + filesystemLabel, + sizeOption, + minSize, + maxSize, + }; const { errors, getVisibleError } = useErrors(value); + + const volumeGroupConfig = useVolumeGroupConfig(); + const volumeGroup = useVolumeGroup(); + const logicalVolume = useLogicalVolume(target); + const unusedMountPoints = useUnusedMountPoints(); + const addLogicalVolume = useAddLogicalVolume(); + const editLogicalVolume = useEditLogicalVolume(); + // Initializes the form values if there is an initial value (i.e., when editing a logical volume). React.useEffect(() => { if (initialValue) { setMountPoint(initialValue.mountPoint); setName(initialValue.name); + setTarget(initialValue.target); setFilesystem(initialValue.filesystem); setFilesystemLabel(initialValue.filesystemLabel); setSizeOption(initialValue.sizeOption); @@ -595,6 +803,7 @@ export default function LogicalVolumePage() { }, [ initialValue, setMountPoint, + setTarget, setFilesystem, setFilesystemLabel, setSizeOption, @@ -602,14 +811,14 @@ export default function LogicalVolumePage() { setMaxSize, ]); - const refreshFilesystemHandler = useCallback( + const refreshFilesystemHandler = React.useCallback( (filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem), [autoRefreshFilesystem, setFilesystem], ); useAutoRefreshFilesystem(refreshFilesystemHandler, value); - const refreshSizeHandler = useCallback( + const refreshSizeHandler = React.useCallback( (sizeOption: SizeOptionValue, minSize: string, maxSize: string) => { if (autoRefreshSize) { setSizeOption(sizeOption); @@ -627,10 +836,15 @@ export default function LogicalVolumePage() { setAutoRefreshFilesystem(true); setAutoRefreshSize(true); setMountPoint(value); - setName(configModel.logicalVolume.generateName(value)); } }; + const changeTarget = (value: string) => { + setAutoRefreshFilesystem(true); + setAutoRefreshSize(true); + setTarget(value); + }; + const changeFilesystem = (value: string) => { setAutoRefreshFilesystem(false); setAutoRefreshSize(false); @@ -649,18 +863,19 @@ export default function LogicalVolumePage() { }; const onSubmit = () => { - const data = toData(value); + const logicalVolumeConfig = createLogicalVolumeConfig(value); - if (initialValue) editLogicalVolume(vgName, initialValue.mountPoint, data); - else addLogicalVolume(vgName, data); + if (initialValue) + editLogicalVolume(Number(index), initialValue.mountPoint, logicalVolumeConfig); + else addLogicalVolume(Number(index), logicalVolumeConfig); - navigate(PATHS.root); + navigate({ pathname: PATHS.root, search: location.search }); }; const isFormValid = errors.length === 0; const mountPointError = getVisibleError("mountPoint"); const usedMountPt = mountPointError ? NO_VALUE : mountPoint; - const showLabel = filesystem !== NO_VALUE && usedMountPt !== NO_VALUE; + const showLabel = filesystem !== NO_VALUE && filesystem !== REUSE_FILESYSTEM; const sizeMode: SizeMode = sizeOption === "" ? "auto" : sizeOption; const sizeRange: SizeRange = { min: minSize, max: maxSize }; @@ -668,45 +883,58 @@ export default function LogicalVolumePage() { -
+ - + - - - - - - {!mountPointError && _("Select or enter a mount point")} - {mountPointError?.message} - - - - + + {volumeGroup && ( + + + + )} - - + + + + {!mountPointError && _("Select or enter a mount point")} + {mountPointError?.message} + + + + + {!logicalVolume && ( - - - - + )} + + + + + + + + {showLabel && ( - - + - {showLabel && ( - - - - - - )} - - - - + )} + + + {target === NEW_LOGICAL_VOLUME && ( {usedMountPt === NO_VALUE && (