diff --git a/doc/dbus/org.opensuse.Agama.Storage1.Proposal.doc.xml b/doc/dbus/org.opensuse.Agama.Storage1.Proposal.doc.xml index 018179b46c..9b7bea10ab 100644 --- a/doc/dbus/org.opensuse.Agama.Storage1.Proposal.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Storage1.Proposal.doc.xml @@ -11,6 +11,7 @@ TargetPVDevices as (optional: only makes sense if Target is "newLvmVg") ConfigureBoot b BootDevice s + DefaultBootDevice s EncryptionPassword s EncryptionMethod s EncryptionPBKDFunction s diff --git a/service/Gemfile.lock b/service/Gemfile.lock index a70c881f97..9f8554476d 100755 --- a/service/Gemfile.lock +++ b/service/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - agama (7) + agama-yast (7.devel737) cfa (~> 1.0.2) cfa_grub2 (~> 2.0.0) cheetah (~> 1.0.0) @@ -26,7 +26,9 @@ GEM docile (1.4.0) eventmachine (1.2.7) fast_gettext (2.3.0) - nokogiri (1.15.5-x86_64-linux) + mini_portile2 (2.8.5) + nokogiri (1.15.5) + mini_portile2 (~> 2.8.2) racc (~> 1.4) packaging_rake_tasks (1.5.4) rake @@ -63,7 +65,7 @@ PLATFORMS x86_64-linux-gnu DEPENDENCIES - agama! + agama-yast! byebug packaging_rake_tasks (~> 1.5.1) rake (~> 13.0.6) diff --git a/service/lib/agama/dbus/storage/interfaces/device/drive.rb b/service/lib/agama/dbus/storage/interfaces/device/drive.rb index 0466ad7984..51c2f0d7c0 100644 --- a/service/lib/agama/dbus/storage/interfaces/device/drive.rb +++ b/service/lib/agama/dbus/storage/interfaces/device/drive.rb @@ -85,7 +85,10 @@ def drive_model # # @return [String] def drive_bus - storage_device.bus || "" + # FIXME: not sure whether checking for "none" is robust enough + return "" if storage_device.bus.nil? || storage_device.bus.casecmp?("none") + + storage_device.bus end # Bus Id for DASD @@ -110,7 +113,15 @@ def drive_driver def drive_transport return "" unless storage_device.respond_to?(:transport) - storage_device.transport.to_s + transport = storage_device.transport + return "" if transport.nil? || transport.is?(:unknown) + + # FIXME: transport does not have proper i18n support at yast2-storage-ng, so we are + # just duplicating some logic from yast2-storage-ng here + return "USB" if transport.is?(:usb) + return "IEEE 1394" if transport.is?(:sbp) + + transport.to_s end # More info about the device diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb index d29aa92941..b1d725e69b 100644 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb +++ b/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb @@ -41,6 +41,7 @@ def initialize(settings) # * "CandidatePVDevices" [Array] Optional # * "ConfigureBoot" [Boolean] # * "BootDevice" [String] + # * "DefaultBootDevice" [String] # * "EncryptionPassword" [String] # * "EncryptionMethod" [String] # * "EncryptionPBKDFunction" [String] @@ -65,6 +66,7 @@ def convert DBUS_PROPERTIES = { "ConfigureBoot" => :configure_boot_conversion, "BootDevice" => :boot_device_conversion, + "DefaultBootDevice" => :default_boot_device_conversion, "EncryptionPassword" => :encryption_password_conversion, "EncryptionMethod" => :encryption_method_conversion, "EncryptionPBKDFunction" => :encryption_pbkd_function_conversion, @@ -126,6 +128,11 @@ def boot_device_conversion settings.boot.device || "" end + # @return [String] + def default_boot_device_conversion + settings.default_boot_device || "" + end + # @return [String] def encryption_password_conversion settings.encryption.password.to_s diff --git a/service/lib/agama/storage/proposal_settings.rb b/service/lib/agama/storage/proposal_settings.rb index 9ad451a402..77e142dda7 100644 --- a/service/lib/agama/storage/proposal_settings.rb +++ b/service/lib/agama/storage/proposal_settings.rb @@ -71,6 +71,20 @@ def installation_devices .uniq end + # Default device to use for configuring boot. + # + # @return [String, nil] + def default_boot_device + case device + when DeviceSettings::Disk + device.name + when DeviceSettings::NewLvmVg + device.candidate_pv_devices.min + when DeviceSettings::ReusedLvmVg + # TODO: Decide what device to use. + end + end + private # Device used for booting. diff --git a/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb b/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb index 5ed2c94299..c38add3cf5 100644 --- a/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb +++ b/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb @@ -77,7 +77,7 @@ def device_conversion(target) # @param target [Y2Storage::ProposalSettings] def disk_device_conversion(target) target.lvm = false - target.candidate_devices = [settings.boot.device].compact + target.candidate_devices = [boot_device].compact end # @param target [Y2Storage::ProposalSettings] @@ -109,8 +109,8 @@ def enable_lvm(target) # @param target [Y2Storage::ProposalSettings] def boot_conversion(target) - target.root_device = settings.boot.device target.boot = settings.boot.configure? + target.root_device = boot_device end # @param target [Y2Storage::ProposalSettings] @@ -196,6 +196,13 @@ def find_max_size_fallback(mount_path) volume&.mount_path end + # Device used for booting. + # + # @return [String, nil] + def boot_device + settings.boot.device || settings.default_boot_device + end + # All block devices affected by the space policy. # # @see ProposalSettings#installation_devices diff --git a/service/test/agama/dbus/storage/interfaces/device/drive_examples.rb b/service/test/agama/dbus/storage/interfaces/device/drive_examples.rb index 62595298c4..42d9c082e3 100644 --- a/service/test/agama/dbus/storage/interfaces/device/drive_examples.rb +++ b/service/test/agama/dbus/storage/interfaces/device/drive_examples.rb @@ -192,13 +192,29 @@ allow(device).to receive(:transport).and_return(transport) end - let(:transport) { instance_double(Y2Storage::DataTransport, to_s: "usb") } + let(:transport) { Y2Storage::DataTransport::FCOE } it "returns the transport" do - expect(subject.drive_transport).to eq("usb") + expect(subject.drive_transport).to eq("fcoe") end - context "if transport is unknown" do + context "if transport is Unknown" do + let(:transport) { Y2Storage::DataTransport::UNKNOWN } + + it "returns an empty string" do + expect(subject.drive_transport).to eq("") + end + end + + context "if transport is USB" do + let(:transport) { Y2Storage::DataTransport::USB } + + it "returns the corresponding string" do + expect(subject.drive_transport).to eq("USB") + end + end + + context "if transport is missing" do let(:transport) { nil } it "returns an empty string" do diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb index 047ab626b2..76700c39c5 100644 --- a/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb +++ b/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb @@ -50,6 +50,7 @@ "TargetDevice" => "", "ConfigureBoot" => true, "BootDevice" => "", + "DefaultBootDevice" => "", "EncryptionPassword" => "", "EncryptionMethod" => "luks2", "EncryptionPBKDFunction" => "pbkdf2", @@ -63,6 +64,7 @@ "TargetDevice" => "/dev/sda", "ConfigureBoot" => true, "BootDevice" => "/dev/sdb", + "DefaultBootDevice" => "/dev/sda", "EncryptionPassword" => "notsecret", "EncryptionMethod" => "luks2", "EncryptionPBKDFunction" => "argon2id", diff --git a/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb b/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb index e0ef192136..1fc6087951 100644 --- a/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb +++ b/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb @@ -99,6 +99,7 @@ context "when the device settings is set to use a disk" do before do settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/sda") + settings.boot.device = "/dev/sdb" end it "sets lvm to false" do @@ -107,12 +108,6 @@ expect(y2storage_settings.lvm).to eq(false) end - it "sets the boot device as candidate device" do - y2storage_settings = subject.convert - - expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sda") - end - it "sets the target device as device for the volumes with missing device" do y2storage_settings = subject.convert @@ -122,6 +117,24 @@ an_object_having_attributes(mount_point: "/test3", device: "/dev/sdb") ) end + + it "sets the boot device as candidate device" do + y2storage_settings = subject.convert + + expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sdb") + end + + context "and no boot device is selected" do + before do + settings.boot.device = nil + end + + it "sets the target device as candidate device" do + y2storage_settings = subject.convert + + expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sda") + end + end end context "when the device settings is set to create a new LVM volume group" do @@ -172,16 +185,6 @@ end context "boot conversion" do - before do - settings.boot.device = "/dev/sda" - end - - it "sets the boot device as root device" do - y2storage_settings = subject.convert - - expect(y2storage_settings.root_device).to eq("/dev/sda") - end - context "if boot configuration is enabled" do before do settings.boot.configure = true @@ -205,6 +208,48 @@ expect(y2storage_settings.boot).to eq(false) end end + + context "if a boot device is selected" do + before do + settings.boot.device = "/dev/sda" + end + + it "sets the boot device as root device" do + y2storage_settings = subject.convert + + expect(y2storage_settings.root_device).to eq("/dev/sda") + end + end + + context "if no boot device is selected" do + before do + settings.boot.device = nil + end + + context "and the device settings is set to use a disk" do + before do + settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/sda") + end + + it "sets the target device as root device" do + y2storage_settings = subject.convert + + expect(y2storage_settings.root_device).to eq("/dev/sda") + end + end + + context "and the device settings is set to create a new LVM volume group" do + before do + settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new(["/dev/sda", "/dev/sdb"]) + end + + it "sets the first candidate device as root device" do + y2storage_settings = subject.convert + + expect(y2storage_settings.root_device).to eq("/dev/sda") + end + end + end end context "space policy conversion" do diff --git a/service/test/agama/storage/proposal_settings_test.rb b/service/test/agama/storage/proposal_settings_test.rb index 75f3cb21a8..82fcc58d8b 100644 --- a/service/test/agama/storage/proposal_settings_test.rb +++ b/service/test/agama/storage/proposal_settings_test.rb @@ -25,6 +25,60 @@ require "agama/storage/volume" describe Agama::Storage::ProposalSettings do + describe "#default_boot_device" do + context "when the device is configured to use a disk" do + before do + subject.device = Agama::Storage::DeviceSettings::Disk.new + end + + context "and no device is selected yet" do + before do + subject.device.name = nil + end + + it "returns nil" do + expect(subject.default_boot_device).to be_nil + end + end + + context "and a device is selected" do + before do + subject.device.name = "/dev/sda" + end + + it "returns the target device" do + expect(subject.default_boot_device).to eq("/dev/sda") + end + end + end + + context "when the device is configured to create a new LVM volume group" do + before do + subject.device = Agama::Storage::DeviceSettings::NewLvmVg.new + end + + context "and no device is selected yet" do + before do + subject.device.candidate_pv_devices = [] + end + + it "returns nil" do + expect(subject.default_boot_device).to be_nil + end + end + + context "and some candidate devices for creating the LVM physical volumes are selected" do + before do + subject.device.candidate_pv_devices = ["/dev/sdc", "/dev/sda", "/dev/sdb"] + end + + it "returns the first candidate device in alphabetical order" do + expect(subject.default_boot_device).to eq("/dev/sda") + end + end + end + end + describe "#installation_devices" do shared_examples "boot device" do context "when boot is set to be configured" do diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 43b336bf31..50b32062af 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -424,6 +424,51 @@ ul[data-type="agama/list"][role="grid"] { } } +[data-type="agama/controlled-panels"] { + [data-type="agama/option"] { + label, input { + cursor: pointer; + } + + label { + display: flex; + gap: var(--spacer-smaller); + } + } + + [data-variant="buttons"] { + input { position: absolute; opacity: 0 } + + label { + border: 1px solid var(--color-primary); + padding: var(--spacer-small); + gap: var(--spacer-small); + border-radius: var(--spacer-smaller); + position: relative; + + &:has(input:checked) { + background: var(--color-primary); + color: white; + } + + &:has(input:focus-visible) { + // outline: 1px dotted; + // outline-offset: 0.25rem; + box-shadow: 0 0 0 3px var(--focus-color); + } + } + + [data-type="agama/option"]:not(:last-child) { + border-inline-end: 2px solid var(--color-gray-darker); + padding-inline-end: var(--spacer-small); + } + } + + > div[aria-expanded="false"] { + display: none; + } +} + table[data-type="agama/tree-table"] { th:first-child { block-size: fit-content; @@ -481,6 +526,8 @@ table[data-type="agama/tree-table"] { } } +// FIXME: use a single selector +table.devices-table, table.proposal-result { tr.dimmed-row { background-color: #fff; @@ -670,3 +717,36 @@ section [data-type="agama/reminder"] { max-inline-size: 100%; } } + + +[data-type="agama/expandable-selector"] { + // The expandable selector is built on top of PF/Table#expandable + // Let's tweak some styles + tr { + td:first-child { + padding-inline-start: 0; + } + + td:last-child { + padding-inline-end: 0; + } + } + + tr.pf-v5-c-table__expandable-row.pf-m-expanded { + border-bottom: 0; + + .pf-v5-c-table__expandable-row-content { + font-size: var(--fs-medium); + padding-block: var(--spacer-small); + } + } +} + +#boot-form { + legend { + label { + font-size: var(--fs-large); + font-weight: bold; + } + } +} diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss index 490429fe54..71a5d7e024 100644 --- a/web/src/assets/styles/variables.scss +++ b/web/src/assets/styles/variables.scss @@ -9,6 +9,7 @@ --fw-bold: 700; --fs-small: 0.7rem; + --fs-medium: 12px; --fs-base: 14px; --fs-large: 1rem; --fs-h1: 1.5rem; diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 8cb1f273ac..1412d80d8c 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -113,9 +113,10 @@ const ZFCP_DISK_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Disk"; * @typedef {object} ProposalSettings * @property {string} target * @property {string} [targetDevice] - * @property {string[]} [targetPVDevices] + * @property {string[]} targetPVDevices * @property {boolean} configureBoot * @property {string} bootDevice + * @property {string} defaultBootDevice * @property {string} encryptionPassword * @property {string} encryptionMethod * @property {string} spacePolicy @@ -499,6 +500,7 @@ class ProposalManager { targetPVDevices: buildTargetPVDevices(dbusSettings.TargetPVDevices), configureBoot: dbusSettings.ConfigureBoot.v, bootDevice: dbusSettings.BootDevice.v, + defaultBootDevice: dbusSettings.DefaultBootDevice.v, spacePolicy: dbusSettings.SpacePolicy.v, spaceActions: dbusSettings.SpaceActions.v.map(a => buildSpaceAction(a.v)), encryptionPassword: dbusSettings.EncryptionPassword.v, diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index 73158c05fa..7fc98b979d 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -457,6 +457,7 @@ const contexts = { }, ConfigureBoot: { t: "b", v: true }, BootDevice: { t: "s", v: "/dev/sda" }, + DefaultBootDevice: { t: "s", v: "/dev/sdb" }, EncryptionPassword: { t: "s", v: "00000" }, EncryptionMethod: { t: "s", v: "luks1" }, SpacePolicy: { t: "s", v: "custom" }, @@ -1538,6 +1539,7 @@ describe("#proposal", () => { targetPVDevices: ["/dev/sda", "/dev/sdb"], configureBoot: true, bootDevice: "/dev/sda", + defaultBootDevice: "/dev/sdb", encryptionPassword: "00000", spacePolicy: "custom", spaceActions: [ diff --git a/web/src/components/core/ControlledPanels.jsx b/web/src/components/core/ControlledPanels.jsx new file mode 100644 index 0000000000..6c164607f9 --- /dev/null +++ b/web/src/components/core/ControlledPanels.jsx @@ -0,0 +1,108 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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. + */ + +// @ts-check + +import React from "react"; + +/** + * Wrapper component for holding ControlledPanel options + * + * Useful for rendering the ControlledPanel options horizontally. + * + * @see ControlledPanel examples. + * + * @param {React.PropsWithChildren} props + */ +const Options = ({ children, ...props }) => { + return ( +
+ { children } +
+ ); +}; + +/** + * Renders an option intended to control the visibility of panels referenced by + * the controls prop. + * + * @typedef {object} OptionProps + * @property {string} id - The option id. + * @property {React.AriaAttributes["aria-controls"]} controls - A space-separated of one or more ID values + * referencing the elements whose contents or presence are controlled by the option. + * @property {boolean} isSelected - Whether the option is selected or not. + * @typedef {Omit, "aria-controls">} InputProps + * + * @param {React.PropsWithChildren} props + */ +const Option = ({ id, controls, isSelected, children, ...props }) => { + return ( +
+ +
+ ); +}; + +/** + * Renders content whose visibility will be controlled by an option + * + * @typedef {object} PanelBaseProps + * @property {string} id - The option id. + * @property {boolean} isExpanded - The value for the aria-expanded attribute + * which will determine if the panel is visible or not. + * + * @typedef {PanelBaseProps & Omit, "id" | "aria-expanded">} PanelProps + * + * @param {PanelProps} props + */ +const Panel = ({ id, isExpanded = false, children, ...props }) => { + return ( +
+ { children } +
+ ); +}; + +/** + * TODO: Write the documentation and examples. + * TODO: Find a better name. + * TODO: Improve it. + * NOTE: Please, be aware that despite the name, this has no relation with so + * called React controlled components https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components + * This is just a convenient, dummy component for simplifying the use of this + * options/tabs pattern across Agama UI. + */ +const ControlledPanels = ({ children, ...props }) => { + return ( +
+ { children } +
+ ); +}; + +ControlledPanels.Options = Options; +ControlledPanels.Option = Option; +ControlledPanels.Panel = Panel; + +export default ControlledPanels; diff --git a/web/src/components/core/ExpandableSelector.jsx b/web/src/components/core/ExpandableSelector.jsx new file mode 100644 index 0000000000..7499f37f1d --- /dev/null +++ b/web/src/components/core/ExpandableSelector.jsx @@ -0,0 +1,240 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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. + */ + +// @ts-check + +import React, { useState } from "react"; +import { Table, Thead, Tr, Th, Tbody, Td, ExpandableRowContent, RowSelectVariant } from "@patternfly/react-table"; + +/** + * An object for sharing data across nested maps + * + * Since function arguments are always passed by value, an object passed by + * sharing is needed for sharing data that might be mutated from different + * places, as it is the case of the rowIndex prop here. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions#passing_arguments + * + * @typedef {object} SharedData + * @property {number} rowIndex - The current row index, to be incremented each time a table row is generated. + */ + +/** + * @typedef {object} ExpandableSelectorColumn + * @property {string} name - The column header text. + * @property {(object) => React.ReactNode} value - A function receiving + * the item to work with and returning the column value. + * @property {string} [classNames] - space-separated list of additional CSS class names. + */ + +/** + * Internal component for building the table header + * + * @param {object} props + * @param {ExpandableSelectorColumn[]} props.columns + */ +const TableHeader = ({ columns }) => ( + + + + + { columns?.map((c, i) => {c.name}) } + + +); + +/** + * Helper function for ensuring a good value for ExpandableSelector#itemsSelected prop + * + * It logs information to console.error if given value does not match + * expectations. + * + * @param {*} selection - The value to check. + * @param {boolean} allowMultiple - Whether the returned collection can have + * more than one item + * @return {Array} Empty array if given value is not valid. The first element if + * it is a collection with more than one but selector does not allow multiple. + * The original value otherwise. + */ +const sanitizeSelection = (selection, allowMultiple) => { + if (!Array.isArray(selection)) { + console.error("`itemSelected` prop must be an array. Ignoring given value", selection); + return []; + } + + if (!allowMultiple && selection.length > 1) { + console.error( + "`itemsSelected` prop can only have more than one item when selector `isMultiple`. " + + "Using only the first element" + ); + + return [selection[0]]; + } + + return selection; +}; + +/** + * Build a expandable table with selectable items + * @component + * + * @note It only accepts one nesting level. + * + * @param {object} props + * @param {ExpandableSelectorColumn[]} props.columns - Collection of objects defining columns. + * @param {boolean} [props.isMultiple=false] - Whether multiple selection is allowed. + * @param {object[]} props.items - Collection of items to be rendered. + * @param {string} [props.itemIdKey="id"] - The key for retrieving the item id. + * @param {(item: object) => Array} [props.itemChildren=() => []] - Lookup method to retrieve children from given item. + * @param {(item: object) => boolean} [props.itemSelectable=() => true] - Whether an item will be selectable or not. + * @param {(item: object) => (string|undefined)} [props.itemClassNames=() => ""] - Callback that allows adding additional CSS class names to item row. + * @param {object[]} [props.itemsSelected=[]] - Collection of selected items. + * @param {string[]} [props.initialExpandedKeys=[]] - Ids of initially expanded items. + * @param {(selection: Array) => void} [props.onSelectionChange=noop] - Callback to be triggered when selection changes. + * @param {object} [props.tableProps] - Props for {@link https://www.patternfly.org/components/table/#table PF/Table}. + */ +export default function ExpandableSelector({ + columns = [], + isMultiple = false, + items = [], + itemIdKey = "id", + itemChildren = () => [], + itemSelectable = () => true, + itemClassNames = () => "", + itemsSelected = [], + initialExpandedKeys = [], + onSelectionChange, + ...tableProps +}) { + const [expandedItemsKeys, setExpandedItemsKeys] = useState(initialExpandedKeys); + const selection = sanitizeSelection(itemsSelected, isMultiple); + const isItemSelected = (item) => selection.includes(item); + const isItemExpanded = (key) => expandedItemsKeys.includes(key); + const toggleExpanded = (key) => { + if (isItemExpanded(key)) { + setExpandedItemsKeys(expandedItemsKeys.filter(k => k !== key)); + } else { + setExpandedItemsKeys([...expandedItemsKeys, key]); + } + }; + + const updateSelection = (item) => { + if (!isMultiple) { + onSelectionChange([item]); + return; + } + + if (isItemSelected(item)) { + onSelectionChange(selection.filter(i => i !== item)); + } else { + onSelectionChange([...selection, item]); + } + }; + + /** + * Render method for building the markup for an item child + * + * @param {object} item - The child to be rendered + * @param {boolean} isExpanded - Whether the child should be shown or not + * @param {SharedData} sharedData - An object holding shared data + */ + const renderItemChild = (item, isExpanded, sharedData) => { + const rowIndex = sharedData.rowIndex++; + + const selectProps = { + rowIndex, + onSelect: () => updateSelection(item), + isSelected: isItemSelected(item), + variant: isMultiple ? RowSelectVariant.checkbox : RowSelectVariant.radio + }; + + return ( + + + + { columns?.map((column, index) => ( + + {column.value(item)} + + ))} + + ); + }; + + /** + * Render method for building the markup for item + * + * @param {object} item - The item to be rendered + * @param {SharedData} sharedData - An object holding shared data + */ + const renderItem = (item, sharedData) => { + const itemKey = item[itemIdKey]; + const rowIndex = sharedData.rowIndex++; + const children = itemChildren(item); + const validChildren = Array.isArray(children) && children.length > 0; + const expandProps = validChildren && { + rowIndex, + isExpanded: isItemExpanded(itemKey), + onToggle: () => toggleExpanded(itemKey), + }; + + const selectProps = { + rowIndex, + onSelect: () => updateSelection(item), + isSelected: isItemSelected(item), + variant: isMultiple ? RowSelectVariant.checkbox : RowSelectVariant.radio + }; + + const renderChildren = () => { + if (!validChildren) return; + + return children.map(item => renderItemChild(item, isItemExpanded(itemKey), sharedData)); + }; + + // TODO: Add label to Tbody? + return ( + + + + + { columns?.map((column, index) => ( + + {column.value(item)} + + ))} + + { renderChildren() } + + ); + }; + + // @see SharedData + const sharedData = { rowIndex: 0 }; + + const TableBody = () => items?.map(item => renderItem(item, sharedData)); + + return ( + + + +
+ ); +} diff --git a/web/src/components/core/ExpandableSelector.test.jsx b/web/src/components/core/ExpandableSelector.test.jsx new file mode 100644 index 0000000000..bcfaf8887a --- /dev/null +++ b/web/src/components/core/ExpandableSelector.test.jsx @@ -0,0 +1,449 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { ExpandableSelector } from "~/components/core"; + +const sda = { + sid: "59", + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const sda1 = { + sid: "60", + isDrive: false, + type: "", + active: true, + name: "/dev/sda1", + size: 512, + recoverableSize: 128, + systems : [], + udevIds: [], + udevPaths: [] +}; + +const sda2 = { + sid: "61", + isDrive: false, + type: "", + active: true, + name: "/dev/sda2", + size: 512, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: [] +}; + +sda.partitionTable = { + type: "gpt", + partitions: [sda1, sda2], + unpartitionedSize: 512 +}; + +const sdb = { + sid: "62", + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +const lv1 = { + sid: "163", + name: "/dev/system/vg/lv1", + content: "Personal Data" +}; + +const vg = { + sid: "162", + type: "vg", + name: "/dev/system/vg", + lvs: [ + lv1 + ] +}; + +const columns = [ + { name: "Device", value: (item) => item.name }, + { + name: "Content", + value: (item) => { + if (item.isDrive) return item.systems.map((s, i) =>

{s}

); + if (item.type === "vg") return `${item.lvs.length} logical volume(s)`; + + return item.content; + } + }, + { name: "Size", value: (item) => item.size }, +]; + +const onChangeFn = jest.fn(); + +let props; +const commonProps = { + columns, + items: [sda, sdb, vg], + itemIdKey: "sid", + initialExpandedKeys: [sda.sid, vg.sid], + itemChildren: (item) => ( + item.isDrive ? item.partitionTable?.partitions : item.lvs + ), + onSelectionChange: onChangeFn, + "aria-label": "Device selector" +}; + +describe("ExpandableSelector", () => { + beforeAll(() => { + jest.spyOn(console, "error").mockImplementation(); + }); + + afterAll(() => { + console.error.mockRestore(); + }); + + beforeEach(() => { + props = { ...commonProps }; + }); + + it("renders a table with given name", () => { + plainRender(); + screen.getByRole("grid", { name: "Device selector" }); + }); + + it("renders the table headers", () => { + plainRender(); + const table = screen.getByRole("grid"); + within(table).getByRole("columnheader", { name: "Device" }); + within(table).getByRole("columnheader", { name: "Content" }); + within(table).getByRole("columnheader", { name: "Size" }); + }); + + it("renders a rowgroup per parent item", () => { + plainRender(); + const groups = screen.getAllByRole("rowgroup"); + // NOTE: since has also the rowgroup role, we expect to found 4 in + // this example: 1 thead + 3 tbody (sda, sdb, vg) + expect(groups.length).toEqual(4); + }); + + it("renders a row per given item and found children", () => { + plainRender(); + const table = screen.getByRole("grid"); + within(table).getByRole("row", { name: /dev\/sda 1024/ }); + within(table).getByRole("row", { name: /dev\/sdb 2048/ }); + within(table).getByRole("row", { name: /dev\/system\/vg 1 logical/ }); + within(table).getByRole("row", { name: /dev\/sda1 512/ }); + within(table).getByRole("row", { name: /dev\/sda2 512/ }); + within(table).getByRole("row", { name: /Personal Data/ }); + }); + + it("renders a expand toggler in items with children", () => { + plainRender(); + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdbRow = within(table).getByRole("row", { name: /dev\/sdb 2048/ }); + const lvRow = within(table).getByRole("row", { name: /dev\/system\/vg 1 logical/ }); + + within(sdaRow).getByRole("button", { name: "Details" }); + within(lvRow).getByRole("button", { name: "Details" }); + // `/dev/sdb` does not have children, toggler must not be there + const sdbChildrenToggler = within(sdbRow).queryByRole("button", { name: "Details" }); + expect(sdbChildrenToggler).toBeNull(); + }); + + it("renders as expanded items which value for `itemIdKey` is included in `initialExpandedKeys` prop", () => { + plainRender( + + ); + const table = screen.getByRole("grid"); + within(table).getByRole("row", { name: /dev\/sda1 512/ }); + within(table).getByRole("row", { name: /dev\/sda2 512/ }); + }); + + it("keeps track of expanded items", async () => { + const { user } = plainRender( + + ); + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /sda 1024/ }); + const sdaToggler = within(sdaRow).getByRole("button", { name: "Details" }); + const vgRow = within(table).getByRole("row", { name: /vg 1 logical/ }); + const vgToggler = within(vgRow).getByRole("button", { name: "Details" }); + + within(table).getByRole("row", { name: /dev\/sda1 512/ }); + within(table).getByRole("row", { name: /dev\/sda2 512/ }); + + await user.click(vgToggler); + + within(table).getByRole("row", { name: /Personal Data/ }); + + await user.click(sdaToggler); + const sdaPartitionsRows = within(table).queryAllByRole("row", { name: /sda[d] 512/ }); + expect(sdaPartitionsRows.length).toEqual(0); + }); + + it("uses 'id' as key when `itemIdKey` prop is not given", () => { + plainRender( + + ); + + const table = screen.getByRole("grid"); + // Since itemIdKey does not match the id used for the item, they are + // collapsed by default and their children are not visible + const sdaChild = within(table).queryByRole("row", { name: /dev\/sda1 512/ }); + expect(sdaChild).toBeNull(); + }); + + it("uses given `itemIdKey` as key", () => { + plainRender( + + ); + + const table = screen.getByRole("grid"); + // Since itemIdKey === "name", "/dev/sda" is properly mounted as expanded. Its + // children must be visible + const sdaChild = within(table).queryByRole("row", { name: /dev\/sda1 512/ }); + expect(sdaChild).not.toBeNull(); + }); + + describe("when `itemsSelected` is given", () => { + it("renders nothing as checked if value is an empty array", () => { + plainRender(); + const table = screen.getByRole("grid"); + const selection = within(table).queryAllByRole("radio", { checked: true }); + expect(selection.length).toEqual(0); + }); + + describe("but it isn't an array", () => { + it("outputs to console.error", () => { + plainRender(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("prop must be an array"), + "Whatever" + ); + }); + + it("renders nothing as selected", () => { + plainRender(); + const table = screen.getByRole("grid"); + const selection = within(table).queryAllByRole("radio", { checked: true }); + expect(selection.length).toEqual(0); + }); + }); + }); + + describe("when mounted as single selector", () => { + describe.each([undefined, null, false])("because isMultiple={%s}", (isMultiple) => { + beforeEach(() => { + props = { ...props, isMultiple }; + }); + + it("renders a radio per item row", () => { + plainRender(); + const table = screen.getByRole("grid"); + const radios = within(table).getAllByRole("radio"); + expect(radios.length).toEqual(6); + }); + + describe("but `itemSelectable` is given", () => { + it("renders a radio only for items for which it returns true", () => { + const itemSelectable = (item) => item.isDrive || item.type === "vg"; + plainRender(); + const table = screen.getByRole("grid"); + const radios = within(table).getAllByRole("radio"); + + // Expected only three radios + expect(radios.length).toEqual(3); + + // Not in below items + const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ }); + const sda2Row = within(table).getByRole("row", { name: /dev\/sda2/ }); + const lv1Row = within(table).getByRole("row", { name: /lv1/ }); + expect(within(sda1Row).queryAllByRole("radio")).toEqual([]); + expect(within(sda2Row).queryAllByRole("radio")).toEqual([]); + expect(within(lv1Row).queryAllByRole("radio")).toEqual([]); + }); + }); + + describe("and `itemsSelected` is given", () => { + describe("and it is an array with just one item", () => { + it("renders it as checked", async () => { + plainRender(); + const table = screen.getByRole("grid"); + const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ }); + const selection = screen.getAllByRole("radio", { checked: true }); + expect(selection.length).toEqual(1); + within(sda1Row).getByRole("radio", { checked: true }); + }); + }); + + describe("but it is an array with more than one item", () => { + it("outputs to console.error", () => { + plainRender(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Using only the first element") + ); + }); + + it("renders the first one as checked", async () => { + plainRender(); + const table = screen.getByRole("grid"); + const selection = screen.getAllByRole("radio", { checked: true }); + const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ }); + const lv1Row = within(table).getByRole("row", { name: /Personal Data/ }); + const lv1Radio = within(lv1Row).getByRole("radio"); + within(sda1Row).getByRole("radio", { checked: true }); + expect(lv1Radio).not.toHaveAttribute("checked", true); + expect(selection.length).toEqual(1); + }); + }); + }); + + describe("and user selects an already selected item", () => { + it("does not trigger the `onSelectionChange` callback", async () => { + const { user } = plainRender(); + const sda1row = screen.getByRole("row", { name: /dev\/sda1/ }); + const sda1radio = within(sda1row).getByRole("radio"); + await user.click(sda1radio); + expect(onChangeFn).not.toHaveBeenCalled(); + }); + }); + + describe("and user selects a not selected item", () => { + it("calls the `onSelectionChange` callback with a collection holding only selected item", async () => { + const { user } = plainRender(); + const sda2row = screen.getByRole("row", { name: /dev\/sda2/ }); + const sda2radio = within(sda2row).getByRole("radio"); + await user.click(sda2radio); + expect(onChangeFn).toHaveBeenCalledWith([sda2]); + }); + }); + }); + }); + + describe("when mounted as multiple selector", () => { + beforeEach(() => { + props = { ...props, isMultiple: true }; + }); + + it("renders a checkbox per item row", () => { + plainRender(); + const table = screen.getByRole("grid"); + const checkboxes = within(table).getAllByRole("checkbox"); + expect(checkboxes.length).toEqual(6); + }); + + describe("but `itemSelectable` is given", () => { + it("renders a checkbox only for items for which it returns true", () => { + const itemSelectable = (item) => item.isDrive || item.type === "vg"; + plainRender(); + const table = screen.getByRole("grid"); + const checkboxes = within(table).getAllByRole("checkbox"); + + // Expected only three checkboxes + expect(checkboxes.length).toEqual(3); + + // Not in below items + const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ }); + const sda2Row = within(table).getByRole("row", { name: /dev\/sda2/ }); + const lv1Row = within(table).getByRole("row", { name: /lv1/ }); + expect(within(sda1Row).queryAllByRole("checkbox")).toEqual([]); + expect(within(sda2Row).queryAllByRole("checkbox")).toEqual([]); + expect(within(lv1Row).queryAllByRole("checkbox")).toEqual([]); + }); + }); + + describe("and `itemsSelected` is given", () => { + it("renders given items as checked", async () => { + plainRender(); + const table = screen.getByRole("grid"); + const selection = screen.getAllByRole("checkbox", { checked: true }); + const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ }); + const lv1Row = within(table).getByRole("row", { name: /Personal Data/ }); + within(sda1Row).getByRole("checkbox", { checked: true }); + within(lv1Row).getByRole("checkbox", { checked: true }); + expect(selection.length).toEqual(2); + }); + }); + + it("renders initially selected items given via `itemsSelected` prop", async () => { + plainRender(); + const table = screen.getByRole("grid"); + const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ }); + const lv1Row = within(table).getByRole("row", { name: /Personal Data/ }); + const selection = screen.getAllByRole("checkbox", { checked: true }); + expect(selection.length).toEqual(2); + [sda1Row, lv1Row].forEach(row => within(row).getByRole("checkbox", { checked: true })); + }); + + describe("and user selects an already selected item", () => { + it("triggers the `onSelectionChange` callback with a collection not including the item", async () => { + const { user } = plainRender(); + const sda1row = screen.getByRole("row", { name: /dev\/sda1/ }); + const sda1radio = within(sda1row).getByRole("checkbox"); + await user.click(sda1radio); + expect(onChangeFn).toHaveBeenCalledWith([sda2]); + }); + }); + + describe("and user selects a not selected item", () => { + it("calls the `onSelectionChange` callback with a collection including the item", async () => { + const { user } = plainRender(); + const sda2row = screen.getByRole("row", { name: /dev\/sda2/ }); + const sda2checkbox = within(sda2row).getByRole("checkbox"); + await user.click(sda2checkbox); + expect(onChangeFn).toHaveBeenCalledWith([sda1, sda2]); + }); + }); + }); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index f6448c03d3..7e0a4837ff 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -56,7 +56,9 @@ export { default as NumericTextInput } from "./NumericTextInput"; export { default as PasswordInput } from "./PasswordInput"; export { default as DevelopmentInfo } from "./DevelopmentInfo"; export { default as Selector } from "./Selector"; +export { default as ExpandableSelector } from "./ExpandableSelector"; export { default as OptionsPicker } from "./OptionsPicker"; export { default as Reminder } from "./Reminder"; export { default as Tag } from "./Tag"; export { default as TreeTable } from "./TreeTable"; +export { default as ControlledPanels } from "./ControlledPanels"; diff --git a/web/src/components/overview/StorageSection.jsx b/web/src/components/overview/StorageSection.jsx index 7f5b3a305f..733b2cbe6c 100644 --- a/web/src/components/overview/StorageSection.jsx +++ b/web/src/components/overview/StorageSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -27,7 +27,8 @@ import { useInstallerClient } from "~/context/installer"; import { BUSY } from "~/client/status"; import { deviceLabel } from "~/components/storage/utils"; import { Em, ProgressText, Section } from "~/components/core"; -import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { _, n_ } from "~/i18n"; /** * Text explaining the storage proposal @@ -38,12 +39,73 @@ import { _ } from "~/i18n"; const ProposalSummary = ({ proposal }) => { const { availableDevices = [], result = {} } = proposal; + const label = (deviceName) => { + const device = availableDevices.find(d => d.name === deviceName); + return device ? deviceLabel(device) : deviceName; + }; + + if (result.settings?.target === "newLvmVg") { + // TRANSLATORS: Part of the message describing where the system will be installed. + // Do not translate 'abbr' and 'title', they are part of the HTML markup. + const vg = _("LVM volume group"); + const pvDevices = result.settings?.targetPVDevices; + const fullMsg = (policy, num_pvs) => { + switch (policy) { + case "resize": + // TRANSLATORS: %1$s will be replaced by "LVM volume group" (already translated and with some markup) + // %2$s (if present) will be replaced by a device name and its size (eg. "/dev/sda, 20 GiB") + return n_( + "Install in a new %1$s on %2$s shrinking existing partitions as needed", + "Install in a new %1$s shrinking existing partitions at the underlying devices as needed", + num_pvs + ); + case "keep": + // TRANSLATORS: %1$s will be replaced by "LVM volume group" (already translated and with some markup) + // %2$s (if present) will be replaced by a device name and its size (eg. "/dev/sda, 20 GiB") + return n_( + "Install in a new %1$s on %2$s without modifying existing partitions", + "Install in a new %1$s without modifying the partitions at the underlying devices", + num_pvs + ); + case "delete": + // TRANSLATORS: %1$s will be replaced by "LVM volume group" (already translated and with some markup) + // %2$s (if present) will be replaced by a device name and its size (eg. "/dev/sda, 20 GiB") + return n_( + "Install in a new %1$s on %2$s deleting all its content", + "Install in a new %1$s deleting all the content of the underlying devices", + num_pvs + ); + case "custom": + // TRANSLATORS: %1$s will be replaced by "LVM volume group" (already translated and with some markup) + // %2$s (if present) will be replaced by a device name and its size (eg. "/dev/sda, 20 GiB") + return n_( + "Install in a new %1$s on %2$s using a custom strategy to find the needed space", + "Install in a new %1$s using a custom strategy to find the needed space at the underlying devices", + num_pvs + ); + } + }; + + const msg = sprintf(fullMsg(result.settings?.spacePolicy, pvDevices.length), vg, "%dev%"); + + if (pvDevices.length > 1) { + return (); + } else { + const [msg1, msg2] = msg.split("%dev%"); + + return ( + + + { label(pvDevices[0]) } + + + ); + } + } + const targetDevice = result.settings?.targetDevice; if (!targetDevice) return {_("No device selected yet")}; - const device = availableDevices.find(d => d.name === targetDevice); - const label = device ? deviceLabel(device) : targetDevice; - const fullMsg = (policy) => { switch (policy) { case "resize": @@ -60,17 +122,16 @@ const ProposalSummary = ({ proposal }) => { return _("Install using device %s and deleting all its content"); } - console.log(`Unknown space policy: ${policy}`); // TRANSLATORS: %s will be replaced by the device name and its size, // example: "/dev/sda, 20 GiB" - return _("Install using device %s"); + return _("Install using device %s with a custom strategy to find the needed space"); }; const [msg1, msg2] = fullMsg(result.settings?.spacePolicy).split("%s"); return ( - {msg1}{label}{msg2} + {msg1}{label(targetDevice)}{msg2} ); }; diff --git a/web/src/components/storage/BootSelectionDialog.jsx b/web/src/components/storage/BootSelectionDialog.jsx new file mode 100644 index 0000000000..e33b03998f --- /dev/null +++ b/web/src/components/storage/BootSelectionDialog.jsx @@ -0,0 +1,187 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { Form } from "@patternfly/react-core"; +import { _ } from "~/i18n"; +import { DevicesFormSelect } from "~/components/storage"; +import { noop } from "~/utils"; +import { Popup } from "~/components/core"; +import { deviceLabel } from "~/components/storage/utils"; +import { sprintf } from "sprintf-js"; + +/** + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +const BOOT_AUTO_ID = "boot-auto"; +const BOOT_MANUAL_ID = "boot-manual"; +const BOOT_DISABLED_ID = "boot-disabled"; +const OPTIONS_NAME = "boot-mode"; + +/** + * Internal component for building the options + * @component + * + * @param {React.PropsWithChildren>} props + */ +const RadioOption = ({ id, onChange, defaultChecked, children }) => { + return ( + <> + + + + ); +}; + +/** + * Renders a dialog that allows the user to select the boot configuration. + * @component + * + * @typedef {object} Boot + * @property {boolean} configureBoot + * @property {StorageDevice|undefined} bootDevice + * + * @param {object} props + * @param {boolean} props.configureBoot - Whether the boot is configurable + * @param {StorageDevice|undefined} props.bootDevice - Currently selected booting device. + * @param {StorageDevice|undefined} props.defaultBootDevice - Default booting device. + * @param {StorageDevice[]} props.devices - Devices that user can select to boot from. + * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. + * @param {function} [props.onCancel=noop] + * @param {(boot: Boot) => void} [props.onAccept=noop] + */ +export default function BootSelectionDialog({ + configureBoot: configureBootProp, + bootDevice: bootDeviceProp, + defaultBootDevice, + devices, + isOpen, + onCancel = noop, + onAccept = noop, + ...props +}) { + const [configureBoot, setConfigureBoot] = useState(configureBootProp); + const [bootDevice, setBootDevice] = useState(bootDeviceProp || defaultBootDevice); + const [isBootAuto, setIsBootAuto] = useState(configureBootProp && bootDeviceProp === undefined); + + const isBootManual = configureBoot && !isBootAuto; + + const selectBootAuto = () => { + setConfigureBoot(true); + setIsBootAuto(true); + }; + + const selectBootManual = () => { + setConfigureBoot(true); + setIsBootAuto(false); + }; + + const selectBootDisabled = () => { + setConfigureBoot(false); + setIsBootAuto(false); + }; + + const onSubmit = (e) => { + e.preventDefault(); + const device = isBootAuto ? undefined : bootDevice; + onAccept({ configureBoot, bootDevice: device }); + }; + + const isAcceptDisabled = () => { + return isBootManual && bootDevice === undefined; + }; + + const description = _( + "To ensure the new system is able to boot, the installer may need to create or configure some \ +partitions in the appropriate disk." + ); + + const automaticText = () => { + if (!defaultBootDevice) { + return _("Partitions to boot will be allocated at the installation disk."); + } + + return sprintf( + // TRANSLATORS: %s is replaced by a device name and size (e.g., "/dev/sda, 500GiB") + _("Partitions to boot will be allocated at the installation disk (%s)."), + deviceLabel(defaultBootDevice) + ); + }; + + return ( + +
+
+ + selectBootAuto()}> + {_("Automatic")} + + +
+ {automaticText()} +
+
+ +
+ + selectBootManual()}> + {_("Select a disk")} + + + +
+
+ {_("Partitions to boot will be allocated at the following device.")} +
+ +
+
+ +
+ + selectBootDisabled()}> + {_("Do not configure")} + + +
+ {_("No partitions will be automatically configured for booting. Use with caution.")} +
+
+
+ + + + +
+ ); +} diff --git a/web/src/components/storage/BootSelectionDialog.test.jsx b/web/src/components/storage/BootSelectionDialog.test.jsx new file mode 100644 index 0000000000..7e759a1115 --- /dev/null +++ b/web/src/components/storage/BootSelectionDialog.test.jsx @@ -0,0 +1,243 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { BootSelectionDialog } from "~/components/storage"; + +const sda = { + sid: 59, + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const sdb = { + sid: 62, + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +const sdc = { + sid: 63, + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdc", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +let props; + +describe("BootSelectionDialog", () => { + beforeEach(() => { + props = { + isOpen: true, + configureBoot: false, + devices: [sda, sdb, sdc], + onCancel: jest.fn(), + onAccept: jest.fn() + }; + }); + + const automaticOption = () => screen.queryByRole("radio", { name: "Automatic" }); + const selectDiskOption = () => screen.queryByRole("radio", { name: "Select a disk" }); + const notConfigureOption = () => screen.queryByRole("radio", { name: "Do not configure" }); + const diskSelector = () => screen.queryByRole("combobox", { name: /choose a disk/i }); + + it("offers an option to configure boot in the installation disk", () => { + plainRender(); + expect(automaticOption()).toBeInTheDocument(); + }); + + it("offers an option to configure boot in a selected disk", () => { + plainRender(); + expect(selectDiskOption()).toBeInTheDocument(); + expect(diskSelector()).toBeInTheDocument(); + }); + + it("offers an option to not configure boot", () => { + plainRender(); + expect(notConfigureOption()).toBeInTheDocument(); + }); + + describe("if the current value is set to boot from the installation disk", () => { + beforeEach(() => { + props.configureBoot = true; + props.bootDevice = undefined; + }); + + it("selects 'Automatic' option by default", () => { + plainRender(); + expect(automaticOption()).toBeChecked(); + expect(selectDiskOption()).not.toBeChecked(); + expect(diskSelector()).toBeDisabled(); + expect(notConfigureOption()).not.toBeChecked(); + }); + }); + + describe("if the current value is set to boot from a selected disk", () => { + beforeEach(() => { + props.configureBoot = true; + props.bootDevice = sdb; + }); + + it("selects 'Select a disk' option by default", () => { + plainRender(); + expect(automaticOption()).not.toBeChecked(); + expect(selectDiskOption()).toBeChecked(); + expect(diskSelector()).toBeEnabled(); + expect(notConfigureOption()).not.toBeChecked(); + }); + }); + + describe("if the current value is set to not configure boot", () => { + beforeEach(() => { + props.configureBoot = false; + props.bootDevice = sdb; + }); + + it("selects 'Do not configure' option by default", () => { + plainRender(); + expect(automaticOption()).not.toBeChecked(); + expect(selectDiskOption()).not.toBeChecked(); + expect(diskSelector()).toBeDisabled(); + expect(notConfigureOption()).toBeChecked(); + }); + }); + + it("does not call onAccept on cancel", async () => { + const { user } = plainRender(); + const cancel = screen.getByRole("button", { name: "Cancel" }); + + await user.click(cancel); + + expect(props.onAccept).not.toHaveBeenCalled(); + }); + + describe("if the 'Automatic' option is selected", () => { + beforeEach(() => { + props.configureBoot = false; + props.bootDevice = undefined; + }); + + it("calls onAccept with the selected options on accept", async () => { + const { user } = plainRender(); + + await user.click(automaticOption()); + + const accept = screen.getByRole("button", { name: "Confirm" }); + await user.click(accept); + + expect(props.onAccept).toHaveBeenCalledWith({ + configureBoot: true, + bootDevice: undefined + }); + }); + }); + + describe("if the 'Select a disk' option is selected", () => { + beforeEach(() => { + props.configureBoot = false; + props.bootDevice = undefined; + }); + + it("calls onAccept with the selected options on accept", async () => { + const { user } = plainRender(); + + await user.click(selectDiskOption()); + const selector = diskSelector(); + const sdbOption = within(selector).getByRole("option", { name: /sdb/ }); + await user.selectOptions(selector, sdbOption); + + const accept = screen.getByRole("button", { name: "Confirm" }); + await user.click(accept); + + expect(props.onAccept).toHaveBeenCalledWith({ + configureBoot: true, + bootDevice: sdb + }); + }); + }); + + describe("if the 'Do not configure' option is selected", () => { + beforeEach(() => { + props.configureBoot = true; + props.bootDevice = undefined; + }); + + it("calls onAccept with the selected options on accept", async () => { + const { user } = plainRender(); + + await user.click(notConfigureOption()); + + const accept = screen.getByRole("button", { name: "Confirm" }); + await user.click(accept); + + expect(props.onAccept).toHaveBeenCalledWith({ + configureBoot: false, + bootDevice: undefined + }); + }); + }); +}); diff --git a/web/src/components/storage/DeviceSelectionDialog.jsx b/web/src/components/storage/DeviceSelectionDialog.jsx new file mode 100644 index 0000000000..fd6972c0f9 --- /dev/null +++ b/web/src/components/storage/DeviceSelectionDialog.jsx @@ -0,0 +1,178 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { Form } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { deviceChildren } from "~/components/storage/utils"; +import { ControlledPanels as Panels, Popup } from "~/components/core"; +import { DeviceSelectorTable } from "~/components/storage"; +import { noop } from "~/utils"; + +/** + * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +const SELECT_DISK_ID = "select-disk"; +const CREATE_LVM_ID = "create-lvm"; +const SELECT_DISK_PANEL_ID = "panel-for-disk-selection"; +const CREATE_LVM_PANEL_ID = "panel-for-lvm-creation"; +const OPTIONS_NAME = "selection-mode"; + +const Html = ({ children, ...props }) => ( +
+); + +/** + * Renders a dialog that allows the user to select a target device for installation. + * @component + * + * @param {object} props + * @param {string} props.target + * @param {StorageDevice|undefined} props.targetDevice + * @param {StorageDevice[]} props.targetPVDevices + * @param {StorageDevice[]} props.devices - The actions to perform in the system. + * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. + * @param {function} [props.onCancel=noop] + * @param {(target: Target) => void} [props.onAccept=noop] + * + * @typedef {object} Target + * @property {string} target + * @property {StorageDevice|undefined} targetDevice + * @property {StorageDevice[]} targetPVDevices + + */ +export default function DeviceSelectionDialog({ + target: defaultTarget, + targetDevice: defaultTargetDevice, + targetPVDevices: defaultPVDevices, + devices, + isOpen, + onCancel = noop, + onAccept = noop, + ...props +}) { + const [target, setTarget] = useState(defaultTarget); + const [targetDevice, setTargetDevice] = useState(defaultTargetDevice); + const [targetPVDevices, setTargetPVDevices] = useState(defaultPVDevices); + + const isTargetDisk = target === "disk"; + const isTargetNewLvmVg = target === "newLvmVg"; + + const selectTargetDisk = () => setTarget("disk"); + const selectTargetNewLvmVG = () => setTarget("newLvmVg"); + + const selectTargetDevice = (devices) => setTargetDevice(devices[0]); + + const onSubmit = (e) => { + e.preventDefault(); + onAccept({ target, targetDevice, targetPVDevices }); + }; + + const isAcceptDisabled = () => { + if (isTargetDisk) return targetDevice === undefined; + if (isTargetNewLvmVg) return targetPVDevices.length === 0; + + return true; + }; + + const isDeviceSelectable = (device) => device.isDrive || device.type === "md"; + + return ( + +
+ + + + {_("Select a disk")} + + + {_("Create an LVM Volume Group")} + + + + + + { + // TRANSLATORS: beware the HTML markup ( and ) + _("The file systems will be allocated by default as new partitions in the selected device.") + } + + + + + + + + { + // TRANSLATORS: beware the HTML markup ( and ) + _("The file systems will be allocated by default as logical volumes of a new LVM Volume \ +Group. The corresponding physical volumes will be created on demand as new partitions at the selected devices.") + } + + + + + +
+ + + + +
+ ); +} diff --git a/web/src/components/storage/DeviceSelectionDialog.test.jsx b/web/src/components/storage/DeviceSelectionDialog.test.jsx new file mode 100644 index 0000000000..bb450ccf8c --- /dev/null +++ b/web/src/components/storage/DeviceSelectionDialog.test.jsx @@ -0,0 +1,297 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { DeviceSelectionDialog } from "~/components/storage"; + +const sda = { + sid: 59, + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const sdb = { + sid: 62, + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +const sdc = { + sid: 63, + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdc", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +let props; + +const expectSelector = (selector) => { + const option = (name) => { + const row = within(selector).getByRole("row", { name }); + return within(row).queryByRole("radio") || within(row).queryByRole("checkbox"); + }; + + const matchers = (modifier = (obj) => obj) => { + return { + toHaveCheckedOption: (name) => { + modifier(expect(option(name))).toBeChecked(); + }, + toBeVisible: () => { + // Jsdom does not report correct styles, see https://github.com/jsdom/jsdom/issues/2986. + // expect(selector).not.toBeVisible(); + modifier(expect(selector.parentNode)).toHaveAttribute("aria-expanded", "true"); + } + }; + }; + + return { ...matchers(), not: { ...matchers((obj) => obj.not) } }; +}; + +const clickSelectorOption = async (user, selector, name) => { + const row = within(selector).getByRole("row", { name }); + const option = within(row).queryByRole("radio") || within(row).queryByRole("checkbox"); + await user.click(option); +}; + +describe("DeviceSelectionDialog", () => { + beforeEach(() => { + props = { + isOpen: true, + target: "disk", + targetPVDevices: [], + devices: [sda, sdb, sdc], + onCancel: jest.fn(), + onAccept: jest.fn() + }; + }); + + it("offers an option to select a disk as target device for installation", () => { + plainRender(); + screen.getByRole("radio", { name: "Select a disk" }); + }); + + it("offers an option to create a new LVM volume group as target device for installation", () => { + plainRender(); + screen.getByRole("radio", { name: "Create an LVM Volume Group" }); + }); + + describe("if the target is a disk", () => { + beforeEach(() => { + props.target = "disk"; + props.targetDevice = sda; + }); + + it("selects the disk option by default", () => { + plainRender(); + const diskOption = screen.getByRole("radio", { name: /select a disk/i }); + expect(diskOption).toBeChecked(); + const lvmOption = screen.getByRole("radio", { name: /create an lvm/i }); + expect(lvmOption).not.toBeChecked(); + }); + + it("shows the disk selector", async () => { + plainRender(); + const diskSelector = screen.getByRole("grid", { name: /selector for target disk/i }); + expect(diskSelector).toBeVisible(); + const lvmSelector = screen.getByRole("grid", { name: /selector for new lvm/i }); + expectSelector(lvmSelector).not.toBeVisible(); + }); + + it("shows the target disk as selected", () => { + plainRender(); + const selector = screen.getByRole("grid", { name: /selector for target disk/i }); + expectSelector(selector).toHaveCheckedOption(/sda/); + expectSelector(selector).not.toHaveCheckedOption(/sdb/); + expectSelector(selector).not.toHaveCheckedOption(/sdc/); + }); + + it("allows to switch to new LVM", async () => { + const { user } = plainRender(); + const lvmOption = screen.getByRole("radio", { name: /create an lvm/i }); + expect(lvmOption).not.toBeChecked(); + + await user.click(lvmOption); + + expect(lvmOption).toBeChecked(); + const diskOption = screen.getByRole("radio", { name: /select a disk/i }); + expect(diskOption).not.toBeChecked(); + const lvmSelector = screen.getByRole("grid", { name: /selector for new lvm/i }); + expect(lvmSelector).toBeVisible(); + const diskSelector = screen.getByRole("grid", { name: /selector for target disk/i }); + expectSelector(diskSelector).not.toBeVisible(); + }); + }); + + describe("if the target is a new LVM volume group", () => { + beforeEach(() => { + props.target = "newLvmVg"; + props.targetPVDevices = [sda, sdc]; + }); + + it("selects the LVM option by default", () => { + plainRender(); + const lvmOption = screen.getByRole("radio", { name: /create an lvm/i }); + expect(lvmOption).toBeChecked(); + const diskOption = screen.getByRole("radio", { name: /select a disk/i }); + expect(diskOption).not.toBeChecked(); + }); + + it("shows the selector for LVM candidate devices", () => { + plainRender(); + const lvmSelector = screen.getByRole("grid", { name: /selector for new lvm/i }); + expect(lvmSelector).toBeVisible(); + const diskSelector = screen.getByRole("grid", { name: /selector for target disk/i }); + expectSelector(diskSelector).not.toBeVisible(); + }); + + it("shows the current candidate devices as selected", () => { + plainRender(); + const selector = screen.getByRole("grid", { name: /selector for new lvm/i }); + expectSelector(selector).toHaveCheckedOption(/sda/); + expectSelector(selector).not.toHaveCheckedOption(/sdb/); + expectSelector(selector).toHaveCheckedOption(/sdc/); + }); + + it("allows to switch to disk", async () => { + const { user } = plainRender(); + const diskOption = screen.getByRole("radio", { name: /select a disk/i }); + expect(diskOption).not.toBeChecked(); + + await user.click(diskOption); + + expect(diskOption).toBeChecked(); + const diskSelector = screen.getByRole("grid", { name: /selector for target disk/i }); + expect(diskSelector).toBeVisible(); + const lvmOption = screen.getByRole("radio", { name: /create an lvm/i }); + expect(lvmOption).not.toBeChecked(); + const lvmSelector = screen.getByRole("grid", { name: /selector for new lvm/i }); + expectSelector(lvmSelector).not.toBeVisible(); + }); + }); + + it("does not call onAccept on cancel", async () => { + const { user } = plainRender(); + const cancel = screen.getByRole("button", { name: "Cancel" }); + + await user.click(cancel); + + expect(props.onAccept).not.toHaveBeenCalled(); + }); + + describe("if the option to select a disk as target device is selected", () => { + beforeEach(() => { + props.target = "newLvmVg"; + props.targetDevice = sda; + }); + + it("calls onAccept with the selected target and disk on accept", async () => { + const { user } = plainRender(); + + const diskOption = screen.getByRole("radio", { name: /select a disk/i }); + await user.click(diskOption); + + const selector = screen.getByRole("grid", { name: /selector for target disk/i }); + await clickSelectorOption(user, selector, /sdb/); + + const accept = screen.getByRole("button", { name: "Confirm" }); + await user.click(accept); + + expect(props.onAccept).toHaveBeenCalledWith({ + target: "disk", + targetDevice: sdb, + targetPVDevices: [] + }); + }); + }); + + describe("if the option to create a new LVM volume group is selected", () => { + beforeEach(() => { + props.target = "disk"; + props.targetDevice = sdb; + }); + + it("calls onAccept with the selected target and the candidate devices on accept", async () => { + const { user } = plainRender(); + + const lvmOption = screen.getByRole("radio", { name: /create an lvm/i }); + await user.click(lvmOption); + + const selector = screen.getByRole("grid", { name: /selector for new lvm/i }); + await clickSelectorOption(user, selector, /sda/); + await clickSelectorOption(user, selector, /sdc/); + + const accept = screen.getByRole("button", { name: "Confirm" }); + await user.click(accept); + + expect(props.onAccept).toHaveBeenCalledWith({ + target: "newLvmVg", + targetDevice: sdb, + targetPVDevices: [sda, sdc] + }); + }); + }); +}); diff --git a/web/src/components/storage/DeviceSelectorTable.jsx b/web/src/components/storage/DeviceSelectorTable.jsx new file mode 100644 index 0000000000..0653019f70 --- /dev/null +++ b/web/src/components/storage/DeviceSelectorTable.jsx @@ -0,0 +1,61 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { _ } from "~/i18n"; +import { deviceSize } from '~/components/storage/utils'; +import { DeviceExtendedInfo, DeviceContentInfo } from "~/components/storage"; +import { ExpandableSelector } from "~/components/core"; + +/** + * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +const DeviceInfo = ({ device }) => { + if (!device.sid) return _("Unused space"); + + return ; +}; + +const deviceColumns = [ + { name: _("Device"), value: (device) => }, + { name: _("Content"), value: (device) => }, + { name: _("Size"), value: (device) => deviceSize(device.size), classNames: "sizes-column" } +]; + +export default function DeviceSelectorTable({ devices, selected, ...props }) { + return ( + { + if (!device.sid) { + return "dimmed-row"; + } + }} + itemsSelected={selected} + className="devices-table" + {...props} + /> + ); +} diff --git a/web/src/components/storage/DevicesFormSelect.jsx b/web/src/components/storage/DevicesFormSelect.jsx new file mode 100644 index 0000000000..baf73b0098 --- /dev/null +++ b/web/src/components/storage/DevicesFormSelect.jsx @@ -0,0 +1,66 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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. + */ + +// @ts-check + +import React from "react"; +import { FormSelect, FormSelectOption } from '@patternfly/react-core'; +import { deviceSize } from '~/components/storage/utils'; + +/** + * @typedef {import ("@patternfly/react-core").FormSelectProps} PFFormSelectProps + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * A PF/Select for simple device selection + * @component + * + * @example Simple usage + * import { devices, selected } from "somewhere"; + * + * + * + * @typedef {object} DevicesFormSelectBaseProps + * @property {StorageDevice[]} props.devices - Devices to show in the selector. + * @property {StorageDevice} [props.selectedDevice] - Currently selected device. In case of + * @property {(StorageDevice) => void} props.onChange - Callback to be called when the selection changes + * + * @param {DevicesFormSelectBaseProps & Omit} props + */ +export default function DevicesFormSelect({ devices, selectedDevice, onChange, ...otherProps }) { + return ( + /** @ts-expect-error: for some reason using otherProps makes TS complain */ + onChange(devices.find(d => d.sid === Number(value)))} + > + { devices.map(device => ( + + ))} + + ); +} diff --git a/web/src/components/storage/DevicesFormSelect.test.jsx b/web/src/components/storage/DevicesFormSelect.test.jsx new file mode 100644 index 0000000000..0040f91709 --- /dev/null +++ b/web/src/components/storage/DevicesFormSelect.test.jsx @@ -0,0 +1,24 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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. + */ + +describe("DevicesFormSelect", () => { + it.todo("add examples for testing storage/DevicesFormSelect component"); +}); diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx index e8a1793706..0b065a4c0f 100644 --- a/web/src/components/storage/ProposalDeviceSection.jsx +++ b/web/src/components/storage/ProposalDeviceSection.jsx @@ -19,66 +19,54 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; +// @ts-check + +import React, { useState } from "react"; import { Button, - Form, Skeleton, - Switch, - ToggleGroup, ToggleGroupItem, - Tooltip } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { Icon } from "~/components/layout"; -import { If, Section, Popup } from "~/components/core"; -import { DeviceList, DeviceSelector } from "~/components/storage"; +import { DeviceSelectionDialog } from "~/components/storage"; import { deviceLabel } from '~/components/storage/utils'; -import { noop } from "~/utils"; +import { If, Section } from "~/components/core"; +import { sprintf } from "sprintf-js"; +import { compact, noop } from "~/utils"; /** - * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings - * @typedef {import ("~/client/storage").DevicesManager.StorageDevice} StorageDevice + * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice */ /** - * Form for selecting the installation device. - * @component + * Renders a button that allows changing the target device for installation. * * @param {object} props - * @param {string} props.id - Form ID. - * @param {StorageDevice} [props.current] - Currently selected device, if any. - * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection. - * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback. - * - * @callback onSubmitFn - * @param {string} device - Name of the selected device. + * @param {string} props.target + * @param {StorageDevice|undefined} props.targetDevice + * @param {StorageDevice[]} props.targetPVDevices + * @param {import("react").MouseEventHandler} [props.onClick=noop] */ -const InstallationDeviceForm = ({ - id, - current, - devices = [], - onSubmit = noop -}) => { - const [device, setDevice] = useState(current || devices[0]); - - const changeSelected = (deviceId) => { - setDevice(devices.find(d => d.sid === deviceId)); - }; +const TargetDeviceButton = ({ target, targetDevice, targetPVDevices, onClick = noop }) => { + const label = () => { + if (target === "disk" && targetDevice) return deviceLabel(targetDevice); + if (target === "newLvmVg" && targetPVDevices.length > 0) { + if (targetPVDevices.length > 1) return _("new LVM volume group"); + + if (targetPVDevices.length === 1) { + // TRANSLATORS: %s is the disk used for the LVM physical volumes (eg. "/dev/sda, 80 GiB) + return sprintf(_("new LVM volume group on %s"), deviceLabel(targetPVDevices[0])); + } + } - const submitForm = (e) => { - e.preventDefault(); - if (device !== undefined) onSubmit(device); + return _("No device selected yet"); }; return ( -
- - + ); }; @@ -86,310 +74,78 @@ const InstallationDeviceForm = ({ * Allows to select the installation device. * @component * - * @callback onChangeFn - * @param {string} device - Name of the selected device. - * * @param {object} props - * @param {string} [props.current] - Device name, if any. - * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection. - * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading. - * @param {onChangeFn} [props.onChange=noop] - On change callback. + * @param {string} props.target - Installation target ("disk", "newLvmVg", "reusedLvmVg"). + * @param {StorageDevice|undefined} props.targetDevice - Target device (for target "disk"). + * @param {StorageDevice[]} props.targetPVDevices - Target devices for the LVM volume group (target "newLvmVg"). + * @param {StorageDevice[]} props.devices - Available devices for installation. + * @param {boolean} props.isLoading + * @param {(target: Target) => void} props.onChange + * + * @typedef {object} Target + * @property {string} target + * @property {StorageDevice|undefined} targetDevice + * @property {StorageDevice[]} targetPVDevices */ const InstallationDeviceField = ({ - current, - devices = [], - isLoading = false, - onChange = noop + target, + targetDevice, + targetPVDevices, + devices, + isLoading, + onChange }) => { - const [device, setDevice] = useState(); - const [isFormOpen, setIsFormOpen] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); - const openForm = () => setIsFormOpen(true); + const openDialog = () => setIsDialogOpen(true); - const closeForm = () => setIsFormOpen(false); + const closeDialog = () => setIsDialogOpen(false); - const acceptForm = (selectedDevice) => { - closeForm(); - setDevice(selectedDevice); - onChange(selectedDevice); - }; - - useEffect(() => { - setDevice(devices.find(d => d.name === current)); - }, [current, devices, setDevice]); - - /** - * Renders a button that allows changing selected device - * - * NOTE: if a device is already selected, its name and size will be used for - * the button text. Otherwise, a "No device selected" text will be shown. - * - * @param {object} props - * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any. - */ - const DeviceContent = ({ device }) => { - return ( - - ); + const onAccept = ({ target, targetDevice, targetPVDevices }) => { + closeDialog(); + onChange({ target, targetDevice, targetPVDevices }); }; if (isLoading) { return ; } - const description = _("Select the device for installing the system."); - - return ( - <> -
- {_("Installation device")} - -
- - - } - /> - - - {_("Accept")} - - - - - - ); -}; - -/** - * Form for configuring the system volume group. - * @component - * - * @param {object} props - * @param {string} props.id - Form ID. - * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. - * @param {StorageDevice[]} [props.devices=[]] - Available storage devices. - * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback. - * @param {onValidateFn} [props.onValidate=noop] - On validate callback. - * - * @callback onSubmitFn - * @param {string[]} devices - Name of the selected devices. - * - * @callback onValidateFn - * @param {boolean} valid - */ -const LVMSettingsForm = ({ - id, - settings, - devices = [], - onSubmit: onSubmitProp = noop, - onValidate = noop -}) => { - const [vgDevices, setVgDevices] = useState(settings.systemVGDevices); - const [isBootDeviceSelected, setIsBootDeviceSelected] = useState(settings.systemVGDevices.length === 0); - const [editedDevices, setEditedDevices] = useState(false); - - const selectBootDevice = () => { - setIsBootDeviceSelected(true); - onValidate(true); - }; - - const selectCustomDevices = () => { - setIsBootDeviceSelected(false); - const { bootDevice } = settings; - const customDevices = (vgDevices.length === 0 && !editedDevices) ? [bootDevice] : vgDevices; - setVgDevices(customDevices); - onValidate(customDevices.length > 0); - }; - - const onChangeDevices = (selection) => { - const selectedDevices = devices.filter(d => selection.includes(d.sid)).map(d => d.name); - setVgDevices(selectedDevices); - setEditedDevices(true); - onValidate(devices.length > 0); - }; - - const onSubmit = (e) => { - e.preventDefault(); - const customDevices = isBootDeviceSelected ? [] : vgDevices; - onSubmitProp(customDevices); - }; - - const BootDevice = () => { - const bootDevice = devices.find(d => d.name === settings.bootDevice); - - // FIXME: In this case, should be a "readOnly" selector. - return ; - }; - return ( -
-
- {_("Devices for creating the volume group")} - - - - -
+
+ {_("Installation device")} + } - else={ - vgDevices?.includes(d.name))} + condition={isDialogOpen} + then={ + } /> - - ); -}; - -/** - * Allows to select LVM and configure the system volume group. - * @component - * - * @param {object} props - * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. - * @param {StorageDevice[]} [props.devices=[]] - Available storage devices. - * @param {boolean} [props.isChecked=false] - Whether LVM is selected. - * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading. - * @param {onChangeFn} [props.onChange=noop] - On change callback. - * - * @callback onChangeFn - * @param {boolean} lvm - */ -const LVMField = ({ - settings, - devices = [], - isChecked: isCheckedProp = false, - isLoading = false, - onChange: onChangeProp = noop -}) => { - const [isChecked, setIsChecked] = useState(); - const [isFormOpen, setIsFormOpen] = useState(false); - const [isFormValid, setIsFormValid] = useState(true); - - const onChange = (_, value) => { - setIsChecked(value); - onChangeProp({ lvm: value, vgDevices: [] }); - }; - - const openForm = () => setIsFormOpen(true); - - const closeForm = () => setIsFormOpen(false); - - const onValidateForm = (valid) => setIsFormValid(valid); - - const onSubmitForm = (vgDevices) => { - closeForm(); - onChangeProp({ vgDevices }); - }; - - useEffect(() => { - setIsChecked(isCheckedProp); - }, [isCheckedProp, setIsChecked]); - - const description = _("Configuration of the system volume group. All the file systems will be \ -created in a logical volume of the system volume group."); - - const LVMSettingsButton = () => { - return ( - - - - ); - }; - - if (isLoading) return ; - - return ( -
- - } /> - - - - - {_("Accept")} - - - -
); }; /** - * Section for editing the selected device + * Section for editing the target device for installation. * @component * - * @callback onChangeFn - * @param {object} settings - * * @param {object} props * @param {ProposalSettings} props.settings * @param {StorageDevice[]} [props.availableDevices=[]] - * @param {boolean} [isLoading=false] - * @param {onChangeFn} [props.onChange=noop] + * @param {boolean} [props.isLoading=false] + * @param {(settings: object) => void} [props.onChange=noop] */ export default function ProposalDeviceSection({ settings, @@ -397,20 +153,18 @@ export default function ProposalDeviceSection({ isLoading = false, onChange = noop }) { - const targetDevice = settings.targetDevice; - - const changeBootDevice = (device) => { - if (device.name !== targetDevice) { - onChange({ targetDevice: device.name }); - } - }; - - const changeLVM = ({ lvm, vgDevices }) => { - const settings = {}; - if (lvm !== undefined) settings.lvm = lvm; - if (vgDevices !== undefined) settings.systemVGDevices = vgDevices; - - onChange(settings); + const findDevice = (name) => availableDevices.find(a => a.name === name); + + const target = settings.target; + const targetDevice = findDevice(settings.targetDevice); + const targetPVDevices = compact(settings.targetPVDevices?.map(findDevice) || []); + + const changeTarget = ({ target, targetDevice, targetPVDevices }) => { + onChange({ + target, + targetDevice: targetDevice?.name, + targetPVDevices: targetPVDevices.map(d => d.name) + }); }; const Description = () => ( @@ -426,22 +180,17 @@ Volume Group for installation.") return (
} > -
); diff --git a/web/src/components/storage/ProposalDeviceSection.test.jsx b/web/src/components/storage/ProposalDeviceSection.test.jsx index e30db63e18..3a85b1224f 100644 --- a/web/src/components/storage/ProposalDeviceSection.test.jsx +++ b/web/src/components/storage/ProposalDeviceSection.test.jsx @@ -66,64 +66,21 @@ const sdb = { udevPaths: ["pci-0000:00-19"] }; -const vda = { - sid: "59", - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/vda", - size: 1024, - systems: ["Windows", "openSUSE Leap 15.2"], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - partitionTable: { type: "gpt", partitions: [] } -}; - -const md0 = { - sid: "62", - type: "md", - level: "raid0", - uuid: "12345:abcde", - members: ["/dev/vdb"], - active: true, - name: "/dev/md0", - size: 2048, - systems: [], - udevIds: [], - udevPaths: [] -}; - -const md1 = { - sid: "63", - type: "md", - level: "raid0", - uuid: "12345:abcde", - members: ["/dev/vdc"], - active: true, - name: "/dev/md1", - size: 4096, - systems: [], - udevIds: [], - udevPaths: [] -}; - -const props = { - settings: { - target: "disk", - targetDevice: "/dev/sda", - }, - availableDevices: [sda, sdb], - isLoading: false, - onChange: jest.fn() -}; +let props; describe("ProposalDeviceSection", () => { + beforeEach(() => { + props = { + settings: { + target: "disk", + targetDevice: "/dev/sda", + }, + availableDevices: [sda, sdb], + isLoading: false, + onChange: jest.fn() + }; + }); + describe("Installation device field", () => { describe("when set as loading", () => { beforeEach(() => { @@ -132,7 +89,7 @@ describe("ProposalDeviceSection", () => { describe("and selected device is not defined yet", () => { beforeEach(() => { - props.settings = { targetDevice: undefined }; + props.settings.target = undefined; }); it("renders a loading hint", () => { @@ -141,257 +98,134 @@ describe("ProposalDeviceSection", () => { }); }); }); - describe("when installation device is not selected yet", () => { - beforeEach(() => { - props.settings = { targetDevice: "" }; - }); - - it("uses a 'No device selected yet' text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "No device selected yet" }); - await user.click(button); - - screen.getByRole("dialog", { name: "Installation device" }); - }); - }); - - describe("when installation device is selected", () => { + describe("when the target is a disk", () => { beforeEach(() => { - props.settings = { targetDevice: "/dev/sda" }; + props.settings.target = "disk"; }); - it("uses its name as part of the text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: /\/dev\/sda/ }); - - await user.click(button); - - screen.getByRole("dialog", { name: "Installation device" }); - }); - }); - - it("allows changing the selected device", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: "Installation device" }); - const sdbOption = within(selector).getByRole("radio", { name: /sdb/ }); - const accept = within(selector).getByRole("button", { name: "Accept" }); - - await user.click(sdbOption); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).toHaveBeenCalledWith({ targetDevice: sdb.name }); - }); - - it("allows canceling a device selection", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: "Installation device" }); - const sdbOption = within(selector).getByRole("radio", { name: /sdb/ }); - const cancel = within(selector).getByRole("button", { name: "Cancel" }); - - await user.click(sdbOption); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); - }); - - it("does not trigger the onChange callback when selection actually did not change", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: "Installation device" }); - const sdaOption = within(selector).getByRole("radio", { name: /sda/ }); - const sdbOption = within(selector).getByRole("radio", { name: /sdb/ }); - const accept = within(selector).getByRole("button", { name: "Accept" }); + describe("and installation device is not selected yet", () => { + beforeEach(() => { + props.settings.targetDevice = ""; + }); - // User selects a different device - await user.click(sdbOption); - // but then goes back to the selected device - await user.click(sdaOption); - // and clicks on Accept button - await user.click(accept); + it("uses a 'No device selected yet' text for the selection button", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "No device selected yet" }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - // There is no reason for triggering the onChange callback - expect(props.onChange).not.toHaveBeenCalled(); - }); - }); + await user.click(button); - describe("LVM field", () => { - describe("if LVM setting is not set yet", () => { - beforeEach(() => { - props.settings = {}; + screen.getByRole("dialog", { name: /Device for installing/i }); + }); }); - it("does not render the LVM switch", () => { - plainRender(); - - expect(screen.queryByLabelText(/Use logical volume/)).toBeNull(); - }); - }); + describe("and an installation device is selected", () => { + beforeEach(() => { + props.settings.targetDevice = "/dev/sda"; + }); - describe("if LVM setting is set", () => { - beforeEach(() => { - props.settings = { lvm: false }; - }); + it("uses its name as part of the text for the selection button", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /\/dev\/sda/ }); - it("renders the LVM switch", () => { - plainRender(); + await user.click(button); - screen.getByRole("checkbox", { name: /Use logical volume/ }); + screen.getByRole("dialog", { name: /Device for installing/i }); + }); }); }); - describe("if LVM is set to true", () => { + describe("when the target is a new LVM volume group", () => { beforeEach(() => { - props.availableDevices = [vda, md0, md1]; - props.settings = { bootDevice: "/dev/vda", lvm: true, systemVGDevices: [] }; - props.onChange = jest.fn(); - }); - - it("renders the LVM switch as selected", () => { - plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - expect(checkbox).toBeChecked(); - }); - - it("renders a button for changing the LVM settings", () => { - plainRender(); - - screen.getByRole("button", { name: /LVM settings/ }); - }); - - it("changes the selection on click", async () => { - const { user } = plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - await user.click(checkbox); - - expect(checkbox).not.toBeChecked(); - expect(props.onChange).toHaveBeenCalled(); + props.settings.target = "newLvmVg"; }); - describe("and user clicks on LVM settings", () => { - it("opens the LVM settings dialog", async () => { - const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("System Volume Group"); + describe("and the target devices are not selected yet", () => { + beforeEach(() => { + props.settings.targetPVDevices = []; }); - it("allows selecting either installation device or custom devices", async () => { + it("uses a 'No device selected yet' text for the selection button", async () => { const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); + const button = screen.getByRole("button", { name: "No device selected yet" }); - await user.click(settingsButton); + await user.click(button); - const popup = await screen.findByRole("dialog"); - screen.getByText("System Volume Group"); + screen.getByRole("dialog", { name: /Device for installing/i }); + }); + }); - within(popup).getByRole("button", { name: "Installation device" }); - within(popup).getByRole("button", { name: "Custom devices" }); + describe("and there is a selected device", () => { + beforeEach(() => { + props.settings.targetPVDevices = ["/dev/sda"]; }); - it("allows to set the installation device as system volume group", async () => { + it("uses its name as part of the text for the selection button", async () => { const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - screen.getByText("System Volume Group"); + const button = screen.getByRole("button", { name: /new LVM .* \/dev\/sda/ }); - const bootDeviceButton = within(popup).getByRole("button", { name: "Installation device" }); - const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" }); - const acceptButton = within(popup).getByRole("button", { name: "Accept" }); + await user.click(button); - await user.click(customDevicesButton); - await user.click(bootDeviceButton); - await user.click(acceptButton); + screen.getByRole("dialog", { name: /Device for installing/i }); + }); + }); - expect(props.onChange).toHaveBeenCalledWith( - expect.objectContaining({ systemVGDevices: [] }) - ); + describe("and there are more than one selected device", () => { + beforeEach(() => { + props.settings.targetPVDevices = ["/dev/sda", "/dev/sdb"]; }); - it("allows customize the system volume group", async () => { + it("does not use the names as part of the text for the selection button", async () => { const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - screen.getByText("System Volume Group"); - - const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" }); - const acceptButton = within(popup).getByRole("button", { name: "Accept" }); + const button = screen.getByRole("button", { name: "new LVM volume group" }); - await user.click(customDevicesButton); + await user.click(button); - const vdaOption = within(popup).getByRole("row", { name: /vda/ }); - const md0Option = within(popup).getByRole("row", { name: /md0/ }); - const md1Option = within(popup).getByRole("row", { name: /md1/ }); - - // unselect the boot devices - await user.click(vdaOption); - - await user.click(md0Option); - await user.click(md1Option); - - await user.click(acceptButton); - - expect(props.onChange).toHaveBeenCalledWith( - expect.objectContaining({ systemVGDevices: ["/dev/md0", "/dev/md1"] }) - ); + screen.getByRole("dialog", { name: /Device for installing/i }); }); }); }); - describe("if LVM is set to false", () => { - beforeEach(() => { - props.settings = { lvm: false }; - props.onChange = jest.fn(); - }); + it("allows changing the selected device", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - it("renders the LVM switch as not selected", () => { - plainRender(); + await user.click(button); - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - expect(checkbox).not.toBeChecked(); - }); + const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); + const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); + const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); + const sdbOption = within(sdbRow).getByRole("radio"); + const accept = within(selector).getByRole("button", { name: "Confirm" }); - it("does not render a button for changing the LVM settings", () => { - plainRender(); + await user.click(sdbOption); + await user.click(accept); - const button = screen.queryByRole("button", { name: /LVM settings/ }); - expect(button).toBeNull(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(props.onChange).toHaveBeenCalledWith({ + target: "disk", + targetDevice: sdb.name, + targetPVDevices: [] }); + }); + + it("allows canceling a device selection", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - it("changes the selection on click", async () => { - const { user } = plainRender(); + await user.click(button); - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - await user.click(checkbox); + const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); + const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); + const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); + const sdbOption = within(sdbRow).getByRole("radio"); + const cancel = within(selector).getByRole("button", { name: "Cancel" }); - expect(checkbox).toBeChecked(); - expect(props.onChange).toHaveBeenCalled(); - }); + await user.click(sdbOption); + await user.click(cancel); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(props.onChange).not.toHaveBeenCalled(); }); }); }); diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index b09df47b5a..516fff030f 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -129,7 +129,7 @@ it("loads the proposal data", async () => { await screen.findByText(/\/dev\/vda/); }); -it("renders the device, settings, find space and result sections", async () => { +it("renders the device, settings and result sections", async () => { installerRender(); await screen.findByText(/Device/); diff --git a/web/src/components/storage/ProposalResultSection.jsx b/web/src/components/storage/ProposalResultSection.jsx index 675bdbe48b..0f4f60249e 100644 --- a/web/src/components/storage/ProposalResultSection.jsx +++ b/web/src/components/storage/ProposalResultSection.jsx @@ -28,7 +28,7 @@ import { _, n_ } from "~/i18n"; import { deviceChildren, deviceSize } from "~/components/storage/utils"; import DevicesManager from "~/components/storage/DevicesManager"; import { If, Section, Reminder, Tag, TreeTable } from "~/components/core"; -import { ProposalActionsDialog } from "~/components/storage"; +import { ProposalActionsDialog, FilesystemLabel } from "~/components/storage"; /** * @typedef {import ("~/client/storage").Action} Action @@ -139,11 +139,6 @@ const DevicesTreeTable = ({ devicesManager }) => { return item.description; }; - const renderFilesystemLabel = (item) => { - const label = item.filesystem?.label; - if (label) return {label}; - }; - const renderPTableType = (item) => { // TODO: Create a map for partition table types and use an here. const type = item.partitionTable?.type; @@ -154,7 +149,7 @@ const DevicesTreeTable = ({ devicesManager }) => { return ( <>
{renderNewLabel(item)}
-
{renderContent(item)} {renderFilesystemLabel(item)} {renderPTableType(item)}
+
{renderContent(item)} {renderPTableType(item)}
); }; diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 871024d380..f61b14f787 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -20,14 +20,15 @@ */ import React, { useEffect, useState } from "react"; -import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core"; +import { Button, Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; +import { BootSelectionDialog, ProposalVolumes, ProposalSpacePolicyField } from "~/components/storage"; import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core"; -import { ProposalVolumes, ProposalSpacePolicyField } from "~/components/storage"; import { Icon } from "~/components/layout"; import { noop } from "~/utils"; -import { hasFS } from "~/components/storage/utils"; +import { hasFS, deviceLabel } from "~/components/storage/utils"; /** * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings @@ -267,12 +268,85 @@ const EncryptionField = ({ ); }; +/** + * Allows to select the boot config. + * @component + * + * @param {object} props + * @param {boolean} props.configureBoot + * @param {StorageDevice|undefined} props.bootDevice + * @param {StorageDevice|undefined} props.defaultBootDevice + * @param {StorageDevice[]} props.devices + * @param {boolean} props.isLoading + * @param {(boot: Boot) => void} props.onChange + * + * @typedef {object} Boot + * @property {boolean} configureBoot + * @property {StorageDevice} bootDevice + */ +const BootConfigField = ({ + configureBoot, + bootDevice, + defaultBootDevice, + devices, + isLoading, + onChange +}) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = () => setIsDialogOpen(true); + + const closeDialog = () => setIsDialogOpen(false); + + const onAccept = ({ configureBoot, bootDevice }) => { + closeDialog(); + onChange({ configureBoot, bootDevice }); + }; + + const label = _("Automatically configure any additional partition to boot the system"); + + const value = () => { + if (!configureBoot) return _("nowhere (manual boot setup)"); + + if (!bootDevice) return _("at the installation device"); + + // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) + return sprintf(_("at %s"), deviceLabel(bootDevice)); + }; + + if (isLoading) { + return ; + } + + return ( +
+ {label} + + + } + /> +
+ ); +}; + /** * Section for editing the proposal settings * @component * * @param {object} props * @param {ProposalSettings} props.settings + * @param {StorageDevice[]} [props.availableDevices=[]] * @param {String[]} [props.encryptionMethods=[]] * @param {onChangeFn} [props.onChange=noop] * @@ -281,6 +355,7 @@ const EncryptionField = ({ */ export default function ProposalSettingsSection({ settings, + availableDevices = [], encryptionMethods = [], volumeTemplates = [], isLoading = false, @@ -311,9 +386,19 @@ export default function ProposalSettingsSection({ onChange({ spacePolicy: policy, spaceActions: actions }); }; + const changeBoot = ({ configureBoot, bootDevice }) => { + onChange({ + configureBoot, + bootDevice: bootDevice?.name + }); + }; + const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; - const { volumes = [] } = settings; + const { volumes = [], installationDevices = [] } = settings; + + const bootDevice = availableDevices.find(d => d.name === settings.bootDevice); + const defaultBootDevice = availableDevices.find(d => d.name === settings.defaultBootDevice); // Templates for already existing mount points are filtered out const usefulTemplates = () => { @@ -345,10 +430,18 @@ export default function ProposalSettingsSection({ isLoading={isLoading && settings.volumes === undefined} onChange={changeVolumes} /> + diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index a3e2f873ae..49af0b70a5 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -409,7 +409,7 @@ export default function ProposalVolumes({ - {_("File systems to create in your system")} + {_("File systems to create")} { - const BasicInfo = () => { - const DeviceIcon = () => { - const names = { - raid: "storage", - md: "storage" - }; +const FilesystemLabel = ({ device }) => { + const label = device.filesystem?.label; + if (label) return {label}; +}; - const name = names[device.type] || "hard_drive"; +const DeviceExtendedInfo = ({ device }) => { + const DeviceName = () => { + if (device.name === undefined) return null; - return ; - }; + return
{device.name}
; + }; - const DeviceSize = () => { - if (device.size === undefined) return null; + const DeviceType = () => { + let type; - return
{deviceSize(device.size)}
; - }; + switch (device.type) { + case "multipath": { + // TRANSLATORS: multipath device type + type = _("Multipath"); + break; + } + case "dasd": { + // TRANSLATORS: %s is replaced by the device bus ID + type = sprintf(_("DASD %s"), device.busId); + break; + } + case "md": { + // TRANSLATORS: software RAID device, %s is replaced by the RAID level, e.g. RAID-1 + type = sprintf(_("Software %s"), device.level.toUpperCase()); + break; + } + case "disk": { + if (device.sdCard) { + type = _("SD Card"); + } else { + const technology = device.transport || device.bus; + type = technology + // TRANSLATORS: %s is substituted by the type of disk like "iSCSI" or "SATA" + ? sprintf(_("%s disk"), technology) + : _("Disk"); + } + } + } - return ( -
- - -
- ); + return {type}
} />; }; - const ExtendedInfo = () => { - const DeviceName = () => { - if (device.name === undefined) return null; - - return
{device.name}
; - }; + const DeviceModel = () => { + if (!device.model || device.model === "") return null; - const DeviceType = () => { - let type; + return
{device.model}
; + }; - switch (device.type) { - case "multipath": { - // TRANSLATORS: multipath device type - type = _("Multipath"); - break; - } - case "dasd": { - // TRANSLATORS: %s is replaced by the device bus ID - type = sprintf(_("DASD %s"), device.busId); - break; - } - case "md": { - // TRANSLATORS: software RAID device, %s is replaced by the RAID level, e.g. RAID-1 - type = sprintf(_("Software %s"), device.level.toUpperCase()); - break; - } - case "disk": { - type = device.sdCard - ? _("SD Card") - // TRANSLATORS: %s is replaced by the device transport name, e.g. USB, SATA, SCSI... - : sprintf(_("Transport %s"), device.transport); - } - } + const MDInfo = () => { + if (device.type !== "md" || !device.members) return null; - return {type}
} />; - }; + const members = device.members.map(m => m.split("/").at(-1)); - const DeviceModel = () => { - if (!device.model || device.model === "") return null; + // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array + return
{sprintf(_("Members: %s"), members.sort().join(", "))}
; + }; - return
{device.model}
; - }; + const RAIDInfo = () => { + if (device.type !== "raid") return null; - const MDInfo = () => { - if (device.type !== "md") return null; + const devices = device.devices.map(m => m.split("/").at(-1)); - const members = device.members.map(m => m.split("/").at(-1)); + // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array + return
{sprintf(_("Devices: %s"), devices.sort().join(", "))}
; + }; - // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array - return
{sprintf(_("Members: %s"), members.sort().join(", "))}
; - }; + const MultipathInfo = () => { + if (device.type !== "multipath") return null; - const RAIDInfo = () => { - if (device.type !== "raid") return null; + const wires = device.wires.map(m => m.split("/").at(-1)); - const devices = device.devices.map(m => m.split("/").at(-1)); + // TRANSLATORS: multipath details, %s is replaced by list of connections used by the device + return
{sprintf(_("Wires: %s"), wires.sort().join(", "))}
; + }; - // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array - return
{sprintf(_("Devices: %s"), devices.sort().join(", "))}
; - }; + return ( +
+ + + + + + +
+ ); +}; - const MultipathInfo = () => { - if (device.type !== "multipath") return null; +const DeviceContentInfo = ({ device }) => { + const PTable = () => { + if (device.partitionTable === undefined) return null; - const wires = device.wires.map(m => m.split("/").at(-1)); + const type = device.partitionTable.type.toUpperCase(); + const numPartitions = device.partitionTable.partitions.length; - // TRANSLATORS: multipath details, %s is replaced by list of connections used by the device - return
{sprintf(_("Wires: %s"), wires.sort().join(", "))}
; - }; + // TRANSLATORS: disk partition info, %s is replaced by partition table + // type (MS-DOS or GPT), %d is the number of the partitions + const text = sprintf(_("%s with %d partitions"), type, numPartitions); return (
- - - - - - + {text}
); }; - const ContentInfo = () => { - const PTable = () => { - if (device.partitionTable === undefined) return null; + const Systems = () => { + if (!device.systems || device.systems.length === 0) return null; - const type = device.partitionTable.type.toUpperCase(); - const numPartitions = device.partitionTable.partitions.length; + const System = ({ system }) => { + const logo = /windows/i.test(system) ? "windows_logo" : "linux_logo"; - // TRANSLATORS: disk partition info, %s is replaced by partition table - // type (MS-DOS or GPT), %d is the number of the partitions - const text = sprintf(_("%s with %d partitions"), type, numPartitions); - - return ( -
- {text} -
- ); + return
{system}
; }; - const Systems = () => { - if (device.systems.length === 0) return null; - - const System = ({ system }) => { - const logo = /windows/i.test(system) ? "windows_logo" : "linux_logo"; - - return
{system}
; - }; + return device.systems.map((s, i) => ); + }; - return device.systems.map((s, i) => ); - }; + // TODO: there is a lot of room for improvement here, but first we would need + // device.description (comes from YaST) to be way more granular + const Description = () => { + if (device.partitionTable) return null; - const NotFound = () => { + if (!device.sid || (!!device.model && device.model === device.description)) { // TRANSLATORS: status message, no existing content was found on the disk, // i.e. the disk is completely empty return
{_("No content found")}
; + } + + return
{device.description}
; + }; + + return ( +
+ + + +
+ ); +}; + +/** + * Content for a device item + * @component + * + * @param {Object} props + * @param {StorageDevice} props.device + */ +const DeviceItem = ({ device }) => { + const BasicInfo = () => { + const DeviceIcon = () => { + const names = { + raid: "storage", + md: "storage" + }; + + const name = names[device.type] || "hard_drive"; + + return ; }; - const hasContent = device.partitionTable || device.systems.length > 0; + const DeviceSize = () => { + if (device.size === undefined) return null; + + return
{deviceSize(device.size)}
; + }; return (
- } - else={} - /> + +
); }; @@ -200,13 +214,15 @@ const DeviceItem = ({ device }) => { return (
- - + +
); }; /** + * @todo This component is not used anymore. Remove it. + * * Component for listing storage devices. * @component * @@ -232,6 +248,8 @@ const DeviceList = ({ devices, ...itemProps }) => { const renderDeviceOption = (device) => ; /** + * @todo This component is not used anymore. Remove it. + * * Component for selecting storage devices. * @component * @@ -266,4 +284,4 @@ const DeviceSelector = ({ devices, selected, isMultiple = false, onChange = noop ); }; -export { DeviceList, DeviceSelector }; +export { DeviceList, DeviceSelector, DeviceContentInfo, DeviceExtendedInfo, FilesystemLabel }; diff --git a/web/src/components/storage/device-utils.test.jsx b/web/src/components/storage/device-utils.test.jsx index 10e09e9058..45d5b6864d 100644 --- a/web/src/components/storage/device-utils.test.jsx +++ b/web/src/components/storage/device-utils.test.jsx @@ -143,6 +143,17 @@ const renderOptions = (Component) => { screen.getByText("Micron 1100 SATA"); }); + it("renders the partition table info", () => { + plainRender(); + screen.getByText("GPT with 2 partitions"); + }); + + it("renders systems info", () => { + plainRender(); + screen.getByText("Windows 11"); + screen.getByText("openSUSE Leap 15.2"); + }); + describe("when device is a SDCard", () => { it("renders 'SD Card'", () => { const sdCard = { ...vda, sdCard: true }; @@ -151,26 +162,6 @@ const renderOptions = (Component) => { }); }); - describe("when content is given", () => { - it("renders the partition table info", () => { - plainRender(); - screen.getByText("GPT with 2 partitions"); - }); - - it("renders systems info", () => { - plainRender(); - screen.getByText("Windows 11"); - screen.getByText("openSUSE Leap 15.2"); - }); - }); - - describe("when content is not given", () => { - it("renders 'No content found'", () => { - plainRender(); - screen.getByText("No content found"); - }); - }); - describe("when device is software RAID", () => { it("renders its level", () => { plainRender(); diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index 063d513dab..e16fd5df5e 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -34,5 +34,9 @@ export { default as DASDFormatProgress } from "./DASDFormatProgress"; export { default as ZFCPPage } from "./ZFCPPage"; export { default as ZFCPDiskForm } from "./ZFCPDiskForm"; export { default as ISCSIPage } from "./ISCSIPage"; -export { DeviceList, DeviceSelector } from "./device-utils"; +export { DeviceList, DeviceSelector, DeviceContentInfo, DeviceExtendedInfo, FilesystemLabel } from "./device-utils"; export { default as VolumeForm } from "./VolumeForm"; +export { default as BootSelectionDialog } from "./BootSelectionDialog"; +export { default as DeviceSelectionDialog } from "./DeviceSelectionDialog"; +export { default as DeviceSelectorTable } from "./DeviceSelectorTable"; +export { default as DevicesFormSelect } from "./DevicesFormSelect";