From 27f14b5453aee5cb06c30f391770d8772c1c43e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 20 Jan 2025 07:49:12 +0000 Subject: [PATCH 01/16] web: add route to create partition --- web/src/components/storage/DriveEditor.tsx | 8 ++++-- .../components/storage/ProposalPage.test.tsx | 2 +- web/src/components/storage/dasd/DASDPage.tsx | 4 +-- web/src/components/storage/index.ts | 3 -- web/src/components/storage/zfcp/ZFCPPage.tsx | 4 +-- web/src/routes/paths.ts | 6 ++-- web/src/routes/storage.tsx | 28 +++++++++++-------- 7 files changed, 31 insertions(+), 24 deletions(-) diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 5f2c9456c5..d3015c82eb 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -115,7 +115,7 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { const onToggle = () => setIsOpen(!isOpen); const onSpacePolicyChange = (spacePolicy: configModel.SpacePolicy) => { if (spacePolicy === "custom") { - return navigate(generatePath(PATHS.spacePolicy, { id: baseName(drive.name) })); + return navigate(generatePath(PATHS.findSpace, { id: baseName(drive.name) })); } else { setSpacePolicy(spacePolicy); setIsOpen(false); @@ -311,7 +311,7 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg = false, onChange } return ( navigate(PATHS.targetDevice)} + onClick={() => navigate(PATHS.root)} itemId="lvm" description={_("The configured partitions will be created as logical volumes")} > @@ -611,6 +611,7 @@ const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); const onToggle = () => setIsOpen(!isOpen); return ( @@ -650,6 +651,9 @@ const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { key="add-partition" itemId="add-partition" description={_("Add another partition or mount an existing one")} + onClick={() => + navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) })) + } > {_("Add or use partition")} diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index c4e24e7f47..9b6901952a 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -28,7 +28,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { ProposalPage } from "~/components/storage"; +import ProposalPage from "~/components/storage/ProposalPage"; import { Action, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; jest.mock("~/queries/issues", () => ({ diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index fba4d4b8b9..7f47a3c489 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -50,8 +50,8 @@ export default function DASDPage() { - - {_("Back to device selection")} + + {_("Back")} diff --git a/web/src/components/storage/index.ts b/web/src/components/storage/index.ts index 8cb45ed580..342fbf03dc 100644 --- a/web/src/components/storage/index.ts +++ b/web/src/components/storage/index.ts @@ -20,11 +20,8 @@ * find current contact information at www.suse.com. */ -export { default as ProposalPage } from "./ProposalPage"; export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo"; export { default as ProposalActionsDialog } from "./ProposalActionsDialog"; export { default as ProposalResultSection } from "./ProposalResultSection"; -export { default as ISCSIPage } from "./ISCSIPage"; -export { default as BootSelection } from "./BootSelection"; export { default as DevicesFormSelect } from "./DevicesFormSelect"; export { default as SpaceActionsTable } from "./SpaceActionsTable"; diff --git a/web/src/components/storage/zfcp/ZFCPPage.tsx b/web/src/components/storage/zfcp/ZFCPPage.tsx index caa8ded2af..05ef8a6bb8 100644 --- a/web/src/components/storage/zfcp/ZFCPPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPPage.tsx @@ -196,8 +196,8 @@ export default function ZFCPPage() { - - {_("Back to device selection")} + + {_("Back")} diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 14272d87e1..c4aa4cb923 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -71,9 +71,9 @@ const SOFTWARE = { const STORAGE = { root: "/storage", - targetDevice: "/storage/target-device", - bootDevice: "/storage/boot-device", - spacePolicy: "/storage/space-policy/:id", + bootDevice: "/storage/select-boot-device", + addPartition: "/storage/:id/add-partition", + findSpace: "/storage/:id/find-space", iscsi: "/storage/iscsi", dasd: "/storage/dasd", zfcp: { diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 3cd1f9bd71..27014d5402 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -21,18 +21,20 @@ */ import React from "react"; +import { redirect } from "react-router-dom"; +import { N_ } from "~/i18n"; +import { Route } from "~/types/routes"; import BootSelection from "~/components/storage/BootSelection"; import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; -import { ISCSIPage, ProposalPage } from "~/components/storage"; - -import { Route } from "~/types/routes"; +import ProposalPage from "~/components/storage/ProposalPage"; +import ISCSIPage from "~/components/storage/ISCSIPage"; +import PartitionPage from "~/components/storage/PartitionPage"; +import ZFCPPage from "~/components/storage/zfcp/ZFCPPage"; +import ZFCPDiskActivationPage from "~/components/storage/zfcp/ZFCPDiskActivationPage"; +import DASDPage from "~/components/storage/dasd/DASDPage"; import { supportedDASD, probeDASD } from "~/api/storage/dasd"; import { probeZFCP, supportedZFCP } from "~/api/storage/zfcp"; -import { redirect } from "react-router-dom"; -import { ZFCPPage, ZFCPDiskActivationPage } from "~/components/storage/zfcp"; -import { DASDPage } from "~/components/storage/dasd"; import { STORAGE as PATHS } from "~/routes/paths"; -import { N_ } from "~/i18n"; const routes = (): Route => ({ path: PATHS.root, @@ -47,9 +49,13 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.spacePolicy, + path: PATHS.findSpace, element: , }, + { + path: PATHS.addPartition, + element: , + }, { path: PATHS.iscsi, element: , @@ -60,7 +66,7 @@ const routes = (): Route => ({ element: , handle: { name: N_("DASD") }, loader: async () => { - if (!supportedDASD()) return redirect(PATHS.targetDevice); + if (!supportedDASD()) return redirect(PATHS.root); return probeDASD(); }, }, @@ -69,7 +75,7 @@ const routes = (): Route => ({ element: , handle: { name: N_("ZFCP") }, loader: async () => { - if (!supportedZFCP()) return redirect(PATHS.targetDevice); + if (!supportedZFCP()) return redirect(PATHS.root); return probeZFCP(); }, }, @@ -77,7 +83,7 @@ const routes = (): Route => ({ path: PATHS.zfcp.activateDisk, element: , loader: async () => { - if (!supportedZFCP()) return redirect(PATHS.targetDevice); + if (!supportedZFCP()) return redirect(PATHS.root); return probeZFCP(); }, }, From fb3125bc651e4e8fcf20b77d9fa282879bc0640c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Jan 2025 12:31:45 +0000 Subject: [PATCH 02/16] fix(storage): export sizes for volumes with auto size --- .../dbus/storage/volume_conversion/to_dbus.rb | 15 ++++-- .../storage/volume_conversion/to_dbus_test.rb | 52 +++++++++++++++---- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb index 786aa1e6fa..bdfa28cc89 100644 --- a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb +++ b/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb @@ -51,7 +51,7 @@ def convert "Target" => volume.location.target.to_s, "TargetDevice" => volume.location.device.to_s, "FsType" => volume.fs_type&.to_human_string || "", - "MinSize" => volume.min_size&.to_i, + "MinSize" => min_size_conversion, "AutoSize" => volume.auto_size?, "Snapshots" => volume.btrfs.snapshots?, "Transactional" => volume.btrfs.read_only?, @@ -67,11 +67,20 @@ def convert # @return [Agama::Storage::Volume] attr_reader :volume + # @return [Integer] + def min_size_conversion + min_size = volume.min_size + min_size = volume.outline.base_min_size if volume.auto_size? + min_size.to_i + end + # @param target [Hash] def max_size_conversion(target) - return if volume.max_size.nil? || volume.max_size.unlimited? + max_size = volume.max_size + max_size = volume.outline.base_max_size if volume.auto_size? + return if max_size.unlimited? - target["MaxSize"] = volume.max_size.to_i + target["MaxSize"] = max_size.to_i end # Converts volume outline to D-Bus. diff --git a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb index 9fc2db2749..eedc320987 100644 --- a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb +++ b/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb @@ -26,9 +26,19 @@ require "y2storage/disk_size" describe Agama::DBus::Storage::VolumeConversion::ToDBus do - let(:default_volume) { Agama::Storage::Volume.new("/test") } + let(:volume1) { Agama::Storage::Volume.new("/test1") } - let(:custom_volume) do + let(:volume2) do + Agama::Storage::Volume.new("/test2").tap do |volume| + volume.min_size = nil + volume.max_size = nil + volume.auto_size = true + volume.outline.base_min_size = Y2Storage::DiskSize.new(1024) + volume.outline.base_max_size = Y2Storage::DiskSize.new(4096) + end + end + + let(:volume3) do volume_outline = Agama::Storage::VolumeOutline.new.tap do |outline| outline.required = true outline.filesystems = [Y2Storage::Filesystems::Type::EXT3, Y2Storage::Filesystems::Type::EXT4] @@ -39,9 +49,11 @@ outline.snapshots_size = Y2Storage::DiskSize.new(1000) outline.snapshots_percentage = 10 outline.adjust_by_ram = true + outline.base_min_size = Y2Storage::DiskSize.new(2048) + outline.base_max_size = Y2Storage::DiskSize.new(4096) end - Agama::Storage::Volume.new("/test").tap do |volume| + Agama::Storage::Volume.new("/test3").tap do |volume| volume.outline = volume_outline volume.fs_type = Y2Storage::Filesystems::Type::EXT4 volume.btrfs.snapshots = true @@ -57,8 +69,8 @@ describe "#convert" do it "converts the volume to a D-Bus hash" do - expect(described_class.new(default_volume).convert).to eq( - "MountPath" => "/test", + expect(described_class.new(volume1).convert).to eq( + "MountPath" => "/test1", "MountOptions" => [], "TargetDevice" => "", "Target" => "default", @@ -78,14 +90,36 @@ } ) - expect(described_class.new(custom_volume).convert).to eq( - "MountPath" => "/test", + expect(described_class.new(volume2).convert).to eq( + "MountPath" => "/test2", + "MountOptions" => [], + "TargetDevice" => "", + "Target" => "default", + "FsType" => "", + "MinSize" => 1024, + "MaxSize" => 4096, + "AutoSize" => true, + "Snapshots" => false, + "Transactional" => false, + "Outline" => { + "Required" => false, + "FsTypes" => [], + "SupportAutoSize" => false, + "AdjustByRam" => false, + "SnapshotsConfigurable" => false, + "SnapshotsAffectSizes" => false, + "SizeRelevantVolumes" => [] + } + ) + + expect(described_class.new(volume3).convert).to eq( + "MountPath" => "/test3", "MountOptions" => ["rw", "default"], "TargetDevice" => "/dev/sda", "Target" => "new_partition", "FsType" => "Ext4", - "MinSize" => 1024, - "MaxSize" => 2048, + "MinSize" => 2048, + "MaxSize" => 4096, "AutoSize" => true, "Snapshots" => true, "Transactional" => true, From b293f64e9c396f309ae091d00acd7aa149ca012f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Jan 2025 12:34:04 +0000 Subject: [PATCH 03/16] web: add SelectToggle component --- web/src/components/core/SelectToggle.tsx | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 web/src/components/core/SelectToggle.tsx diff --git a/web/src/components/core/SelectToggle.tsx b/web/src/components/core/SelectToggle.tsx new file mode 100644 index 0000000000..991c74993b --- /dev/null +++ b/web/src/components/core/SelectToggle.tsx @@ -0,0 +1,81 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Select, MenuToggle, MenuToggleElement } from "@patternfly/react-core"; + +export type SelectToggleProps = { + value: number | string; + label?: React.ReactNode; + onChange?: (value: number | string) => void; + isDisabled?: boolean; + children?: React.ReactNode; +}; + +export default function SelectToggle({ + value, + label, + onChange, + isDisabled = false, + children, +}: SelectToggleProps): React.ReactElement { + const [isOpen, setIsOpen] = React.useState(false); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + setIsOpen(false); + onChange && onChange(value as string); + }; + + const toggle = (toggleRef: React.Ref) => { + return ( + + {label || value} + + ); + }; + + return ( + + ); +} From 69b9e667da61fb56f12419bc4ced6d3331e39a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Jan 2025 12:35:13 +0000 Subject: [PATCH 04/16] web: add SelectTypeaheadCreatable component --- .../core/SelectTypeaheadCreatable.tsx | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 web/src/components/core/SelectTypeaheadCreatable.tsx diff --git a/web/src/components/core/SelectTypeaheadCreatable.tsx b/web/src/components/core/SelectTypeaheadCreatable.tsx new file mode 100644 index 0000000000..b24ecefb36 --- /dev/null +++ b/web/src/components/core/SelectTypeaheadCreatable.tsx @@ -0,0 +1,300 @@ +/* + * Copyright (c) [2022-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, +} from "@patternfly/react-core"; +import TimesIcon from "@patternfly/react-icons/dist/esm/icons/times-icon"; +import { _ } from "~/i18n"; + +export type SelectTypeaheadCreatableProps = { + value: string; + options: SelectOptionProps[]; + placeholder?: string; + // Text to show for creating a new option. + createText?: string; + onChange?: (value: string) => void; +}; + +/** + * Allows selecting or creating a value. + * + * Part of this code was taken from the patternfly example, see + * https://www.patternfly.org/components/menus/select#typeahead-with-create-option. + */ +export default function SelectTypeaheadCreatable({ + value, + options, + placeholder, + createText = _("Add"), + onChange, +}: SelectTypeaheadCreatableProps): React.ReactElement { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const [filterValue, setFilterValue] = React.useState(""); + const [selectOptions, setSelectOptions] = React.useState([]); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const CREATE_NEW = "create"; + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = options; + + // Filter menu items based on the text input value when one exists. + if (filterValue) { + newSelectOptions = options.filter((menuItem) => + String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // If no option matches the filter exactly, display creation option. + if (!options.some((option) => option.value === filterValue)) { + newSelectOptions = [ + ...newSelectOptions, + { children: `${createText} "${filterValue}"`, value: CREATE_NEW }, + ]; + } + + // Open the menu when the input value changes and the new value is not empty. + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue, isOpen, options, createText]); + + const createItemId = (value) => `select-typeahead-${value.replace(" ", "-")}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const reset = () => { + setInputValue(value); + setFilterValue(""); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = (value: string | number, content?: string | number) => { + setInputValue(String(content)); + setFilterValue(""); + onChange && onChange(String(value)); + closeMenu(); + }; + + const onSelect = (event: React.UIEvent | undefined, value: string | number | undefined) => { + event.preventDefault(); + if (value) { + if (value === CREATE_NEW) { + selectOption(filterValue, filterValue); + } else { + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); + } + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + resetActiveAndFocusedItem(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + if (key === "ArrowUp") { + // When no index is set or at the first index, focus to the last, otherwise decrement focus + // index. + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.length - 1; + } + } + } + + if (key === "ArrowDown") { + // When no index is set or at the last index, focus to the first, otherwise increment focus + // index. + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options. + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + + switch (event.key) { + case "Enter": + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { + onSelect(event, focusedItem.value as string); + } + + if (!isOpen) { + setIsOpen(true); + } + + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + selectOption("", ""); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + +