diff --git a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml index 93dd879063..6a6b2cb38b 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml @@ -61,6 +61,10 @@ + + + + diff --git a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml index 3a8beb2e6c..b199b5925f 100644 --- a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml @@ -116,6 +116,53 @@ --> + + + + + + + diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index 967bae4bae..6f6bed82aa 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -80,6 +80,7 @@ "additionalProperties": false, "required": ["default"], "properties": { + "reuse": { "type": "boolean" }, "default": { "type": "boolean" }, "type": { "$ref": "#/$defs/filesystemType" }, "snapshots": { "type": "boolean" } diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index b99626b13d..381c747209 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -159,7 +159,7 @@ impl<'a> StorageClient<'a> { Ok(settings) } - /// Set the storage config according to the JSON schema + /// Set the storage config model according to the JSON schema pub async fn set_config_model(&self, model: Box) -> Result { Ok(self .storage_proxy @@ -174,6 +174,13 @@ impl<'a> StorageClient<'a> { Ok(config_model) } + /// Solves the storage config model + pub async fn solve_config_model(&self, model: &str) -> Result, ServiceError> { + let serialized_solved_model = self.storage_proxy.solve_config_model(model).await?; + let solved_model = serde_json::from_str(serialized_solved_model.as_str()).unwrap(); + Ok(solved_model) + } + pub async fn calculate(&self, settings: ProposalSettingsPatch) -> Result { let map: HashMap<&str, zbus::zvariant::Value> = settings.into(); let options: HashMap<&str, &zbus::zvariant::Value> = diff --git a/rust/agama-lib/src/storage/proxies/storage1.rs b/rust/agama-lib/src/storage/proxies/storage1.rs index 563bbfd356..2dc37e05bb 100644 --- a/rust/agama-lib/src/storage/proxies/storage1.rs +++ b/rust/agama-lib/src/storage/proxies/storage1.rs @@ -71,6 +71,9 @@ pub trait Storage1 { /// Get the storage config model according to the JSON schema fn get_config_model(&self) -> zbus::Result; + /// Solve a storage config model + fn solve_config_model(&self, model: &str) -> zbus::Result; + /// DeprecatedSystem property #[zbus(property)] fn deprecated_system(&self) -> zbus::Result; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 99c7e95d22..3bbd61b2d5 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -57,7 +57,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tokio_stream::{Stream, StreamExt}; -use super::license::{License, LicenseContent}; +use super::license::License; #[derive(Clone)] struct SoftwareState<'a> { diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 260f2a9280..60e69beb98 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -115,6 +115,7 @@ pub async fn storage_service(dbus: zbus::Connection) -> Result>, + query: Query, +) -> Result>, Error> { + let solved_model = state + .client + .solve_config_model(query.model.as_str()) + .await + .map_err(Error::Service)?; + Ok(Json(solved_model)) +} + +#[derive(Deserialize, utoipa::IntoParams)] +struct SolveModelQuery { + /// Serialized config model. + model: String, +} + /// Probes the storage devices. #[utoipa::path( post, diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 918f43de6d..938e8fb244 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -147,6 +147,18 @@ def recover_model JSON.pretty_generate(json) end + # Solves the given serialized config model. + # + # @param serialized_model [String] Serialized storage config model. + # @return [String] Serialized solved model. + def solve_model(serialized_model) + logger.info("Solving storage config model from D-Bus: #{serialized_model}") + + model_json = JSON.parse(serialized_model, symbolize_names: true) + solved_model_json = proposal.solve_model(model_json) + JSON.pretty_generate(solved_model_json) + end + def install busy_while { backend.install } end @@ -173,6 +185,9 @@ def deprecated_system busy_while { apply_config_model(serialized_model) } end dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } + dbus_method(:SolveConfigModel, "in sparse_model:s, out solved_model:s") do |sparse_model| + solve_model(sparse_model) + end dbus_method(:Install) { install } dbus_method(:Finish) { finish } dbus_reader(:deprecated_system, "b") 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/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb index d30091a985..1f7f44636a 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb @@ -41,8 +41,9 @@ def default_config # @return [Hash] def conversions { - path: model_json[:mountPath], - type: convert_type + reuse: model_json.dig(:filesystem, :reuse), + path: model_json[:mountPath], + type: convert_type } end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb index bbf1325d40..39314623a3 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb @@ -55,8 +55,8 @@ def conversions filesystem: convert_filesystem, size: convert_size, id: convert_id, - delete: partition_model[:delete], - delete_if_needed: partition_model[:deleteIfNeeded] + delete: convert_delete, + delete_if_needed: convert_delete_if_needed } end @@ -67,6 +67,24 @@ def convert_id Y2Storage::PartitionId.find(value) end + + # TODO: do not delete if the partition is used by other device (VG, RAID, etc). + # @return [Boolean] + def convert_delete + # Do not mark to delete if the partition is used. + return false if partition_model[:mountPath] + + partition_model[:delete] + end + + # TODO: do not delete if the partition is used by other device (VG, RAID, etc). + # @return [Boolean] + def convert_delete_if_needed + # Do not mark to delete if the partition is used. + return false if partition_model[:mountPath] + + partition_model[:deleteIfNeeded] + end end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb index eb3e02b448..f0d0c6cab4 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb @@ -38,6 +38,7 @@ def initialize(config) # @see Base#conversions def conversions { + reuse: config.reuse?, default: convert_default, type: convert_type, snapshots: convert_snapshots diff --git a/service/lib/agama/storage/config_solvers/search.rb b/service/lib/agama/storage/config_solvers/search.rb index c16e5a0582..744cf7e740 100644 --- a/service/lib/agama/storage/config_solvers/search.rb +++ b/service/lib/agama/storage/config_solvers/search.rb @@ -144,7 +144,9 @@ def filter_by_disk_analyzer(devices) # Finds the partitions matching the given search config, if any # # @param search_config [Agama::Storage::Configs::Search] - # @return [Y2Storage::Device, nil] + # @param device [#partitions] + # + # @return [Array] def find_partitions(search_config, device) candidates = candidate_devices(search_config, default: device.partitions) candidates.select! { |d| d.is?(:partition) } @@ -175,7 +177,7 @@ def find_device(search_config) # # @param devices [Array] # @param search [Config::Search] - # @return [Y2Storage::Device, nil] + # @return [Array] def next_unassigned_devices(devices, search) devices .reject { |d| sids.include?(d.sid) } diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 825194f085..394cfd68ef 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -22,6 +22,7 @@ require "agama/issue" require "agama/storage/actions_generator" require "agama/storage/config_conversions" +require "agama/storage/config_solver" require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "json" @@ -100,6 +101,24 @@ def model_json ConfigConversions::ToModel.new(config).convert end + # Solves a given model. + # + # @param model_json [Hash] Config model according to the JSON schema. + # @return [Hash, nil] Solved config model or nil if the model cannot be solved yet. + def solve_model(model_json) + return unless storage_manager.probed? + + config = ConfigConversions::FromModel + .new(model_json, product_config: product_config) + .convert + + ConfigSolver + .new(product_config, storage_manager.probed, disk_analyzer: disk_analyzer) + .solve(config) + + ConfigConversions::ToModel.new(config).convert + end + # Calculates a new proposal using the given JSON. # # @raise If the JSON is not valid. diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index ef777ac41b..23496d79e3 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -36,6 +36,10 @@ require "y2storage" require "dbus" +def serialize(value) + JSON.pretty_generate(value) +end + describe Agama::DBus::Storage::Manager do include Agama::RSpec::StorageHelpers @@ -589,10 +593,6 @@ end describe "#recover_config" do - def serialize(value) - JSON.pretty_generate(value) - end - context "if a proposal has not been calculated" do it "returns 'null'" do expect(subject.recover_config).to eq("null") @@ -682,10 +682,6 @@ def serialize(value) end describe "#recover_model" do - def serialize(value) - JSON.pretty_generate(value) - end - context "if a proposal has not been calculated" do it "returns 'null'" do expect(subject.recover_model).to eq("null") @@ -753,6 +749,7 @@ def serialize(value) { mountPath: "/", filesystem: { + reuse: false, default: true, type: "ext4" }, @@ -792,6 +789,74 @@ def serialize(value) end end + describe "#solve_model" do + let(:model) do + { + drives: [ + { + name: "/dev/sda", + alias: "sda", + partitions: [ + { mountPath: "/" } + ] + } + ] + } + end + + it "returns the serialized solved model" do + result = subject.solve_model(model.to_json) + + expect(result).to eq( + serialize({ + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, + drives: [ + { + name: "/dev/sda", + alias: "sda", + spacePolicy: "keep", + partitions: [ + { + mountPath: "/", + filesystem: { + reuse: false, + default: true, + type: "ext4" + }, + size: { + default: true, + min: 0 + }, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false + } + ] + } + ] + }) + ) + end + + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) + end + + it "returns 'null'" do + result = subject.solve_model(model.to_json) + expect(result).to eq("null") + end + end + end + describe "#iscsi_discover" do it "performs an iSCSI discovery" do expect(iscsi).to receive(:discover_send_targets) do |address, port, auth| 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, diff --git a/service/test/agama/storage/autoyast_proposal_test.rb b/service/test/agama/storage/autoyast_proposal_test.rb index eef85a3920..78ebeb75b5 100644 --- a/service/test/agama/storage/autoyast_proposal_test.rb +++ b/service/test/agama/storage/autoyast_proposal_test.rb @@ -42,7 +42,7 @@ let(:scenario) { "windows-linux-pc.yml" } let(:arch) do - instance_double(Y2Storage::Arch, efiboot?: true, ram_size: 4.GiB.to_i) + instance_double(Y2Storage::Arch, x86?: true, efiboot?: true, ram_size: 4.GiB.to_i) end let(:config_path) do @@ -121,7 +121,7 @@ def root_filesystem(disk) expect(efi).to have_attributes( filesystem_type: Y2Storage::Filesystems::Type::VFAT, filesystem_mountpoint: "/boot/efi", - size: 512.MiB + size: 1.GiB ) expect(root).to have_attributes( @@ -292,7 +292,7 @@ def root_filesystem(disk) filesystem_type: Y2Storage::Filesystems::Type::VFAT, filesystem_mountpoint: "/boot/efi", id: Y2Storage::PartitionId::ESP, - size: 512.MiB + size: 1.GiB ) end diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index c0908f0056..19acc047ac 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -288,6 +288,25 @@ expect(filesystem.mount_options).to eq([]) end end + + context "if the filesystem specifies 'reuse'" do + let(:filesystem) { { reuse: true } } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(true) + expect(filesystem.type.default?).to eq(true) + expect(filesystem.type.fs_type).to be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end end shared_examples "with mountPath and filesystem" do |config_proc| @@ -789,23 +808,47 @@ end context "if a partition specifies 'delete'" do - let(:partitions) { [{ delete: true }] } + let(:partitions) { [{ delete: true, mountPath: mount_path }] } + + let(:mount_path) { nil } it "sets #delete to true" do config = config_proc.call(subject.convert) partition = config.partitions.first expect(partition.delete?).to eq(true) end + + context "and the partition has a mount path" do + let(:mount_path) { "/test" } + + it "sets #delete to false" do + config = config_proc.call(subject.convert) + partition = config.partitions.first + expect(partition.delete?).to eq(false) + end + end end context "if a partition specifies 'deleteIfNeeded'" do - let(:partitions) { [{ deleteIfNeeded: true }] } + let(:partitions) { [{ deleteIfNeeded: true, mountPath: mount_path }] } + + let(:mount_path) { nil } it "sets #delete_if_needed to true" do config = config_proc.call(subject.convert) partition = config.partitions.first expect(partition.delete_if_needed?).to eq(true) end + + context "and the partition has a mount path" do + let(:mount_path) { "/test" } + + it "sets #delete_if_needed to false" do + config = config_proc.call(subject.convert) + partition = config.partitions.first + expect(partition.delete_if_needed?).to eq(false) + end + end end end end diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index f6b23fefed..5cd8af9e33 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -101,6 +101,7 @@ expect(model_json[:mountPath]).to eq("/test") expect(model_json[:filesystem]).to eq( { + reuse: true, default: false, type: "xfs" } @@ -115,6 +116,7 @@ expect(model_json[:mountPath]).to eq("/") expect(model_json[:filesystem]).to eq( { + reuse: false, default: true, type: "btrfs", snapshots: true diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index eaf9eca62e..b3df1a5052 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -376,6 +376,7 @@ def drive(partitions) { mountPath: "/", filesystem: { + reuse: false, default: true, type: "ext4" }, @@ -415,6 +416,72 @@ def drive(partitions) end end + describe "#solve_model" do + let(:model) do + { + drives: [ + { + name: "/dev/sda", + alias: "sda", + partitions: [ + { mountPath: "/" } + ] + } + ] + } + end + + it "returns the solved model" do + result = subject.solve_model(model) + + expect(result).to eq({ + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, + drives: [ + { + name: "/dev/sda", + alias: "sda", + spacePolicy: "keep", + partitions: [ + { + mountPath: "/", + filesystem: { + reuse: false, + default: true, + type: "ext4" + }, + size: { + default: true, + min: 0 + }, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false + } + ] + } + ] + }) + end + + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) + end + + it "returns nil" do + result = subject.solve_model(model) + expect(result).to be_nil + end + end + end + shared_examples "check proposal callbacks" do |action, settings| it "runs all the callbacks" do callback1 = proc {} diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 8ce26ef5da..91907b490b 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -23,6 +23,8 @@ import { get, post, put } from "~/api/http"; import { Job } from "~/types/job"; import { Action, config, configModel, ProductParams, Volume } from "~/api/storage/types"; +import { fetchDevices } from "~/api/storage/devices"; +import { StorageDevice, Volume as StorageVolume, VolumeTarget } from "~/types/storage"; /** * Starts the storage probing process. @@ -43,6 +45,11 @@ const setConfig = (config: config.Config) => put("/api/storage/config", { storag const setConfigModel = (model: configModel.Config) => put("/api/storage/config_model", model); +const solveConfigModel = (model: configModel.Config): Promise => { + const serializedModel = encodeURIComponent(JSON.stringify(model)); + return get(`/api/storage/config_model/solve?model=${serializedModel}`); +}; + const fetchUsableDevices = (): Promise => get(`/api/storage/proposal/usable_devices`); const fetchProductParams = (): Promise => get("/api/storage/product/params"); @@ -52,6 +59,33 @@ const fetchDefaultVolume = (mountPath: string): Promise => { return get(`/api/storage/product/volume_for?mount_path=${path}`); }; +const fetchVolumeTemplates = async (): Promise => { + const buildVolume = ( + rawVolume: Volume, + devices: StorageDevice[], + productMountPoints: string[], + ): StorageVolume => ({ + ...rawVolume, + outline: { + ...rawVolume.outline, + // Indicate whether a volume is defined by the product. + productDefined: productMountPoints.includes(rawVolume.mountPath), + }, + minSize: rawVolume.minSize || 0, + transactional: rawVolume.transactional || false, + target: rawVolume.target as VolumeTarget, + targetDevice: devices.find((d) => d.name === rawVolume.targetDevice), + }); + + const systemDevices = await fetchDevices("system"); + const product = await fetchProductParams(); + const mountPoints = ["", ...product.mountPoints]; + const rawVolumes = await Promise.all(mountPoints.map(fetchDefaultVolume)); + return rawVolumes + .filter((v) => v !== undefined) + .map((v) => buildVolume(v, systemDevices, product.mountPoints)); +}; + const fetchActions = (): Promise => get("/api/storage/devices/actions"); /** @@ -72,9 +106,11 @@ export { fetchConfigModel, setConfig, setConfigModel, + solveConfigModel, fetchUsableDevices, fetchProductParams, fetchDefaultVolume, + fetchVolumeTemplates, fetchActions, fetchStorageJobs, findStorageJob, diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index 2095531c99..61a143416a 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -54,6 +54,7 @@ export interface Drive { partitions?: Partition[]; } export interface Filesystem { + reuse?: boolean; default: boolean; type?: FilesystemType; snapshots?: boolean; diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index ab9bd8ded5..ba331df9b4 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -142,6 +142,36 @@ } } +#productSelectionForm { + input[type="radio"] { + align-self: center; + flex-shrink: 0; + inline-size: 20px; + block-size: 20px; + } + + .pf-v6-c-card { + img { + max-inline-size: 80px; + } + + label { + cursor: pointer; + } + + label::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + } +} + // PatternFly overrides .pf-v6-c-masthead__main { @@ -181,7 +211,8 @@ .pf-v6-c-alert { --pf-v6-c-alert--m-info__title--Color: var(--agm-t--color--waterhole); - --pf-v6-c-alert__icon--FontSize: var(--pf-t--global--font--size--lg); + --pf-v6-c-alert__icon--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--MarginBlockEnd: var(--pf-t--global--spacer--xs); // --pf-v6-c-alert--BoxShadow: var(--pf-t--global--box-shadow--sm); --pf-v6-c-alert--BoxShadow: none; --pf-v6-c-alert--PaddingBlockStart: var(--pf-t--global--spacer--sm); @@ -190,12 +221,12 @@ --pf-v6-c-alert--PaddingInlineEnd: var(--pf-t--global--spacer--md); &:has(.pf-v6-c-alert__description) { - row-gap: var(--pf-t--global--spacer--sm); + row-gap: var(--pf-t--global--spacer--xs); } } .pf-v6-c-alert__title { - font-size: var(--pf-t--global--font--size--lg); + font-size: var(--pf-t--global--font--size--md); } .pf-v6-c-menu { @@ -222,3 +253,26 @@ .pf-v6-c-button { --pf-v6-c-button--BorderRadius: var(--pf-t--global--border--radius--small); } + +// Do not change the default cursor for labels forms because it is confusing +// +// See: +// * https://github.com/openSUSE/Agama/issues/115#issuecomment-1090205696 +// * https://github.com/patternfly/patternfly/issues/4777#issuecomment-1092090484 +.pf-v6-c-form__label { + --pf-v6-c-form__label--hover--Cursor: default; + --pf-v6-c-form__label--m-disabled--hover--Cursor: default; +} + +.pf-v6-c-form { + --pf-v6-c-form__group--m-action--MarginBlockStart: var(--pf-t--global--spacer--md); +} + +// Some utilities not found at PF +.w-14ch { + inline-size: 14ch; +} + +div[aria-live]:empty { + display: none; +} diff --git a/web/src/components/core/NestedContent.test.tsx b/web/src/components/core/NestedContent.test.tsx new file mode 100644 index 0000000000..bedf60167d --- /dev/null +++ b/web/src/components/core/NestedContent.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import NestedContent from "./NestedContent"; + +describe("NestedContent", () => { + it("wraps content in a PF/Content", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-c-content")).toBe(true); + }); + + it("uses inline medium margin when margin prop is not given", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-u-mx-md")).toBe(true); + }); + + it("uses given margin", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-u-m-0")).toBe(true); + }); + + it("allows PF/Content props", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-m-editorial")).toBe(true); + }); +}); diff --git a/web/src/components/core/NestedContent.tsx b/web/src/components/core/NestedContent.tsx new file mode 100644 index 0000000000..2ee583e22e --- /dev/null +++ b/web/src/components/core/NestedContent.tsx @@ -0,0 +1,48 @@ +/* + * 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 { Content, ContentProps } from "@patternfly/react-core"; +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +type NestedContentProps = { + margin?: keyof typeof spacingStyles; +} & ContentProps; + +/** + * Wrapper on top of PF/Content to allow visually nesting its content by adding + * given margin + * + */ +export default function NestedContent({ + margin = "mxMd", + className, + children, + ...props +}: NestedContentProps) { + const classNames = [className, spacingStyles[margin]].join(" "); + return ( + + {children} + + ); +} diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 7141ff5c29..f28685afa1 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -37,7 +37,6 @@ import { PageSection, PageSectionProps, Split, - Stack, Title, TitleProps, } from "@patternfly/react-core"; @@ -93,10 +92,10 @@ const STICK_TO_TOP = Object.freeze({ default: "top" }); const STICK_TO_BOTTOM = Object.freeze({ default: "bottom" }); // TODO: check if it should have the banner role -const Header = ({ hasGutter = true, children, ...props }) => { +const Header = ({ children, ...props }) => { return ( - {children} + {children} ); }; diff --git a/web/src/components/core/SelectTypeaheadCreatable.tsx b/web/src/components/core/SelectTypeaheadCreatable.tsx new file mode 100644 index 0000000000..949c8c4add --- /dev/null +++ b/web/src/components/core/SelectTypeaheadCreatable.tsx @@ -0,0 +1,323 @@ +/* + * 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, + MenuToggleStatus, + 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 = { + id?: string; + value: string; + options: SelectOptionProps[]; + placeholder?: string; + // Text to show for creating a new option. + createText?: string; + onChange?: (value: string) => void; + status?: MenuToggleStatus; + // Accessible name for the toggle + toggleName: string; + // Accessible name for the options list + listName: string; + // Accessible name for input text + inputName: string; + // Accessible name for clear button + clearButtonName: string; +}; + +/** + * 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({ + id, + value, + options, + placeholder, + createText = _("Add"), + onChange, + status, + toggleName, + listName, + inputName, + clearButtonName, +}: 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(() => { + setInputValue(value); + }, [value]); + + 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) => ( + + + + + + } + /> + + + + ); + + return ( + { + reset(); + !isOpen && closeMenu(); + }} + toggle={toggle} + variant="typeahead" + > + + {selectOptions.map((option, index) => ( + + ))} + + + ); +} diff --git a/web/src/components/core/SelectWrapper.test.tsx b/web/src/components/core/SelectWrapper.test.tsx new file mode 100644 index 0000000000..e74a89cf3a --- /dev/null +++ b/web/src/components/core/SelectWrapper.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, waitForElementToBeRemoved, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import SelectWrapper, { SelectWrapperProps } from "~/components/core/SelectWrapper"; +import { SelectList, SelectOption } from "@patternfly/react-core"; + +const TestingSelector = (props: Partial) => ( + + + First + Second + + +); + +describe("SelectWrapper", () => { + it("renders a toggle button using label or value", () => { + const { rerender } = plainRender(); + const button = screen.getByRole("button"); + expect(button.classList.contains("pf-v6-c-menu-toggle")).toBe(true); + within(button).getByText("The label"); + rerender(); + within(button).getByText("The value"); + }); + + it("toggles select options when the toggle button is clicked", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Selector" }); + expect(button).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryAllByRole("option")).toEqual([]); + await user.click(button); + expect(button).toHaveAttribute("aria-expanded", "true"); + expect(screen.queryAllByRole("option").length).toEqual(2); + await user.click(button); + expect(button).toHaveAttribute("aria-expanded", "false"); + await waitForElementToBeRemoved(() => screen.getAllByRole("option")); + }); + + it("toggles select options when an option is clicked", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Selector" }); + await user.click(button); + const firstOption = screen.getByRole("option", { name: "First" }); + await user.click(firstOption); + await waitForElementToBeRemoved(() => screen.getByRole("listbox")); + expect(button).toHaveAttribute("aria-expanded", "false"); + }); + + it("triggers onChange callback when not selected option is clicked", async () => { + const onChangeFn = jest.fn(); + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Selector" }); + await user.click(button); + const secondOption = screen.getByRole("option", { name: "Second" }); + await user.click(secondOption); + expect(onChangeFn).not.toHaveBeenCalled(); + await user.click(button); + const firstOption = screen.getByRole("option", { name: "First" }); + await user.click(firstOption); + expect(onChangeFn).toHaveBeenCalledWith("1"); + }); + + it("focuses the button toggle after selection", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Selector" }); + await user.click(button); + const secondOption = screen.getByRole("option", { name: "Second" }); + await user.click(secondOption); + expect(button).toHaveFocus(); + }); +}); diff --git a/web/src/components/core/SelectWrapper.tsx b/web/src/components/core/SelectWrapper.tsx new file mode 100644 index 0000000000..48b83f72b9 --- /dev/null +++ b/web/src/components/core/SelectWrapper.tsx @@ -0,0 +1,93 @@ +/* + * 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, SelectProps } from "@patternfly/react-core"; + +export type SelectWrapperProps = { + id?: string; + value: number | string; + label?: React.ReactNode; + onChange?: (value: number | string) => void; + isDisabled?: boolean; + // Accessible name for the toggle + toggleName?: string; +} & Omit; + +/** + * Wrapper to simplify the usage of PF/Menu/Select + * + * Abstracts the toggle setup by building it internally based on the received props. + * + * @see https://www.patternfly.org/components/menus/select/ + */ +export default function SelectWrapper({ + id, + value, + label, + onChange, + isDisabled = false, + children, + toggleName, +}: SelectWrapperProps): React.ReactElement { + const [isOpen, setIsOpen] = React.useState(false); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = ( + _: React.MouseEvent | undefined, + nextValue: string | number | undefined, + ) => { + setIsOpen(false); + onChange && nextValue !== value && onChange(nextValue as string); + }; + + const toggle = (toggleRef: React.Ref) => { + return ( + + {label || value} + + ); + }; + + return ( + setIsOpen(isOpen)} + toggle={toggle} + shouldFocusToggleOnSelect + > + {children} + + ); +} diff --git a/web/src/components/core/SubtleContent.test.tsx b/web/src/components/core/SubtleContent.test.tsx new file mode 100644 index 0000000000..a8986a5f94 --- /dev/null +++ b/web/src/components/core/SubtleContent.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import SubtleContent from "./SubtleContent"; + +describe("SubtleContent", () => { + it("wraps content in a PF/Content", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-c-content")).toBe(true); + }); + + it("uses subtle text color", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-u-text-color-subtle")).toBe(true); + }); + + it("allows PF/Content props", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-m-editorial")).toBe(true); + }); +}); diff --git a/web/src/components/core/SubtleContent.tsx b/web/src/components/core/SubtleContent.tsx new file mode 100644 index 0000000000..f2854207e0 --- /dev/null +++ b/web/src/components/core/SubtleContent.tsx @@ -0,0 +1,38 @@ +/* + * 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 { Content, ContentProps } from "@patternfly/react-core"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; + +/** + * Wrapper on top of PF/Content using subtle text color by default + * + */ +export default function NestedContent({ className, children, ...props }: ContentProps) { + const classNames = [className, textStyles.textColorSubtle].join(" "); + return ( + + {children} + + ); +} diff --git a/web/src/components/core/index.ts b/web/src/components/core/index.ts index 879b413304..f1c05854da 100644 --- a/web/src/components/core/index.ts +++ b/web/src/components/core/index.ts @@ -44,4 +44,7 @@ export { default as EmptyState } from "./EmptyState"; export { default as InstallerOptions } from "./InstallerOptions"; export { default as IssuesDrawer } from "./IssuesDrawer"; export { default as Drawer } from "./Drawer"; +export { default as SelectWrapper } from "./SelectWrapper"; +export { default as NestedContent } from "./NestedContent"; +export { default as SubtleContent } from "./SubtleContent"; export { default as MenuHeader } from "./MenuHeader"; diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index 20e21991bd..be55970dca 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -27,6 +27,7 @@ import React from "react"; import AddAPhoto from "@icons/add_a_photo.svg?component"; import Apps from "@icons/apps.svg?component"; import AppRegistration from "@icons/app_registration.svg?component"; +import ArrowRightAlt from "@icons/arrow_right_alt.svg?component"; import Badge from "@icons/badge.svg?component"; import Backspace from "@icons/backspace.svg?component"; import CheckCircle from "@icons/check_circle.svg?component"; @@ -95,6 +96,7 @@ const icons = { add_a_photo: AddAPhoto, apps: Apps, app_registration: AppRegistration, + arrow_right_alt: ArrowRightAlt, badge: Badge, backspace: Backspace, check_circle: CheckCircle, diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index 131eb69bf1..f63f3d39fa 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -26,23 +26,38 @@ import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; import * as ConfigModel from "~/api/storage/types/config-model"; +const sdaDrive: ConfigModel.Drive = { + name: "/dev/sda", + spacePolicy: "delete", + partitions: [], +}; + +const sdbDrive: ConfigModel.Drive = { + name: "/dev/sdb", + spacePolicy: "delete", + partitions: [], +}; + const mockDevices = [ { name: "/dev/sda", size: 536870912000 }, { name: "/dev/sdb", size: 697932185600 }, ]; -const mockConfig = { drives: [] as ConfigModel.Drive[] }; +const mockUseConfigModelFn = jest.fn(); jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), - useConfigModel: () => mockConfig, useDevices: () => mockDevices, - useConfigDevices: () => mockConfig.drives, +})); + +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockUseConfigModelFn(), })); describe("when the configuration does not include any device", () => { beforeEach(() => { - mockConfig.drives = []; + mockUseConfigModelFn.mockReturnValue({ drives: [] }); }); it("indicates that a device is not selected", async () => { @@ -54,7 +69,7 @@ describe("when the configuration does not include any device", () => { describe("when the configuration contains one drive", () => { beforeEach(() => { - mockConfig.drives = [{ name: "/dev/sda", spacePolicy: "delete" }]; + mockUseConfigModelFn.mockReturnValue({ drives: [sdaDrive] }); }); it("renders the proposal summary", async () => { @@ -67,7 +82,7 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'resize'", () => { beforeEach(() => { - mockConfig.drives[0].spacePolicy = "resize"; + mockUseConfigModelFn.mockReturnValue({ drives: [{ ...sdaDrive, spacePolicy: "resize" }] }); }); it("indicates that partitions may be shrunk", async () => { @@ -79,7 +94,7 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'keep'", () => { beforeEach(() => { - mockConfig.drives[0].spacePolicy = "keep"; + mockUseConfigModelFn.mockReturnValue({ drives: [{ ...sdaDrive, spacePolicy: "keep" }] }); }); it("indicates that partitions will be kept", async () => { @@ -89,9 +104,21 @@ describe("when the configuration contains one drive", () => { }); }); + describe("and the space policy is set to 'custom'", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [{ ...sdaDrive, spacePolicy: "custom" }] }); + }); + + it("indicates that custom strategy for allocating space is set", async () => { + plainRender(); + + await screen.findByText(/custom strategy to find the needed space/); + }); + }); + describe("and the drive matches no disk", () => { beforeEach(() => { - mockConfig.drives[0].name = null; + mockUseConfigModelFn.mockReturnValue({ drives: [{ ...sdaDrive, name: null }] }); }); it("indicates that a device is not selected", async () => { @@ -104,10 +131,7 @@ describe("when the configuration contains one drive", () => { describe("when the configuration contains several drives", () => { beforeEach(() => { - mockConfig.drives = [ - { name: "/dev/sda", spacePolicy: "delete" }, - { name: "/dev/sdb", spacePolicy: "delete" }, - ]; + mockUseConfigModelFn.mockReturnValue({ drives: [sdaDrive, sdbDrive] }); }); it("renders the proposal summary", async () => { @@ -116,4 +140,18 @@ describe("when the configuration contains several drives", () => { await screen.findByText(/Install using several devices/); await screen.findByText(/and deleting all its content/); }); + + describe("but one of them has a different space policy", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ + drives: [sdaDrive, { ...sdbDrive, spacePolicy: "resize" }], + }); + }); + + it("indicates that custom strategy for allocating space is set", async () => { + plainRender(); + + await screen.findByText(/custom strategy to find the needed space/); + }); + }); }); diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx index aac3a0bee2..5df7520496 100644 --- a/web/src/components/overview/StorageSection.tsx +++ b/web/src/components/overview/StorageSection.tsx @@ -23,7 +23,8 @@ import React from "react"; import { Content } from "@patternfly/react-core"; import { deviceLabel } from "~/components/storage/utils"; -import { useDevices, useConfigModel } from "~/queries/storage"; +import { useDevices } from "~/queries/storage"; +import { useConfigModel } from "~/queries/storage/config-model"; import { StorageDevice } from "~/types/storage"; import * as ConfigModel from "~/api/storage/types/config-model"; import { _ } from "~/i18n"; @@ -63,18 +64,18 @@ const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => { }; const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): string => { - if (drives.every((d: ConfigModel.Drive) => d.spacePolicy === drives[0].spacePolicy)) { - switch (drives[0].spacePolicy) { - case "resize": - return _("Install using several devices shrinking existing partitions as needed."); - case "keep": - return _("Install using several devices without modifying existing partitions."); - case "delete": - return _("Install using several devices and deleting all its content."); - } + const options = { + resize: _("Install using several devices shrinking existing partitions as needed."), + keep: _("Install using several devices without modifying existing partitions."), + delete: _("Install using several devices and deleting all its content."), + custom: _("Install using several devices with a custom strategy to find the needed space."), + }; + + if (drives.find((d) => d.spacePolicy !== drives[0].spacePolicy)) { + return options.custom; } - return _("Install using several devices with a custom strategy to find the needed space."); + return options[drives[0].spacePolicy]; }; /** diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/AddExistingDeviceMenu.test.tsx index 95d6a7ba68..b2dcf13f74 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.test.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.test.tsx @@ -72,9 +72,12 @@ const mockAddDriveFn = jest.fn(); jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), - useConfigModel: () => mockUseConfigModelFn(), useAvailableDevices: () => [vda, vdb], +})); + +jest.mock("~/queries/storage/config-model", () => ({ useModel: () => ({ addDrive: mockAddDriveFn }), + useConfigModel: () => mockUseConfigModelFn(), })); describe("when there are unused disks", () => { diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx index e4c267efa4..f320730213 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.tsx @@ -34,7 +34,8 @@ import { } from "@patternfly/react-core"; import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; -import { useAvailableDevices, useConfigModel, useModel } from "~/queries/storage"; +import { useAvailableDevices } from "~/queries/storage"; +import { useConfigModel, useModel } from "~/queries/storage/config-model"; import { deviceLabel } from "~/components/storage/utils"; export default function AddExistingDeviceMenu() { diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index 77fa69645b..8a019887f3 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -22,9 +22,9 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Content, Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; +import { ActionGroup, Content, Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; import { DevicesFormSelect } from "~/components/storage"; -import { Page } from "~/components/core"; +import { Page, SubtleContent } from "~/components/core"; import { deviceLabel } from "~/components/storage/utils"; import { StorageDevice } from "~/types/storage"; import { useAvailableDevices } from "~/queries/storage"; @@ -136,94 +136,91 @@ partitions in the appropriate disk.", {_("Boot options")} - {description} + {description} - - - - {_("Automatic")} - - } - body={automaticText()} - /> - - {_("Select a disk")} - - } - body={ - - {_("Partitions to boot will be allocated at the following device.")} - - - } - /> - - {_("Do not configure")} - - } - body={ - - {_( - "No partitions will be automatically configured for booting. Use with caution.", - )} - - } - /> - - + + + {_("Automatic")} + + } + body={automaticText()} + /> + + {_("Select a disk")} + + } + body={ + + {_("Partitions to boot will be allocated at the following device.")} + + + } + /> + + {_("Do not configure")} + + } + body={ + + {_( + "No partitions will be automatically configured for booting. Use with caution.", + )} + + } + /> + + + + + - - - - - ); } diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index a804198617..1e2238f1a9 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -21,7 +21,8 @@ */ import React from "react"; -import { useDevices, useConfigModel } from "~/queries/storage"; +import { useDevices } from "~/queries/storage"; +import { useConfigModel } from "~/queries/storage/config-model"; import DriveEditor from "~/components/storage/DriveEditor"; import { List, ListItem } from "@patternfly/react-core"; diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index c82d3516ff..8ff5222a4f 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -59,6 +59,7 @@ const mockDrive: ConfigModel.Drive = { min: 2_000_000_000, default: false, // false: user provided, true: calculated }, + filesystem: { default: false, type: "swap" }, }, ], }; diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 668ebb978f..d77b275206 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -31,6 +31,7 @@ import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive, usePartition } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; +import * as partitionUtils from "~/components/storage/utils/partition"; import { typeDescription, contentDescription } from "~/components/storage/utils/device"; import { Icon } from "../layout"; import { MenuHeader } from "~/components/core"; @@ -104,7 +105,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); @@ -281,7 +282,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")} > @@ -499,11 +500,12 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { ); }; -const PartitionsNoContentSelector = ({ toggleAriaLabel }) => { +const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { const menuId = useId(); const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); const onToggle = () => setIsOpen(!isOpen); return ( @@ -532,6 +534,9 @@ const PartitionsNoContentSelector = ({ toggleAriaLabel }) => { itemId="add-partition" description={_("Add another partition or mount an existing one")} role="menuitem" + onClick={() => + navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) })) + } > {_("Add or use partition")} @@ -545,13 +550,13 @@ const PartitionsNoContentSelector = ({ toggleAriaLabel }) => { ); }; -const PartitionMenuItem = ({ driveName, mountPath }) => { +const PartitionMenuItem = ({ driveName, mountPath, description }) => { const { delete: deletePartition } = usePartition(driveName, mountPath); return ( @@ -581,6 +586,7 @@ const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); const onToggle = () => setIsOpen(!isOpen); return ( @@ -612,6 +618,7 @@ const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { key={partition.mountPath} driveName={drive.name} mountPath={partition.mountPath} + description={partitionUtils.typeWithSize(partition)} /> ); })} @@ -620,6 +627,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")} @@ -638,7 +648,7 @@ const PartitionsSelector = ({ drive }) => { return ; } - return ; + return ; }; export default function DriveEditor({ drive, driveDevice }: DriveEditorProps) { diff --git a/web/src/components/storage/PartitionPage.test.tsx b/web/src/components/storage/PartitionPage.test.tsx new file mode 100644 index 0000000000..cf81997cf0 --- /dev/null +++ b/web/src/components/storage/PartitionPage.test.tsx @@ -0,0 +1,218 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender, mockParams } from "~/test-utils"; +import PartitionPage from "./PartitionPage"; +import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; +import { configModel } from "~/api/storage/types"; +import { gib } from "./utils"; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: jest.fn(), + useIssues: () => [], +})); + +jest.mock("./ProposalResultSection", () => () => result section); +jest.mock("./ProposalTransactionalInfo", () => () => trasactional info); + +const sda1: StorageDevice = { + sid: 69, + name: "/dev/sda1", + description: "Swap partition", + isDrive: false, + type: "partition", + size: gib(2), + shrinking: { unsupported: ["Resizing is not supported"] }, + start: 1, +}; + +const sda: StorageDevice = { + 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, + shrinking: { unsupported: ["Resizing is not supported"] }, + systems: [], + partitionTable: { + type: "gpt", + partitions: [sda1], + unpartitionedSize: 0, + unusedSlots: [{ start: 3, size: gib(2) }], + }, + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], + description: "", +}; + +const mockDrive: configModel.Drive = { + name: "/dev/sda", + spacePolicy: "delete", + partitions: [ + { + mountPath: "swap", + size: { + min: gib(2), + default: false, // false: user provided, true: calculated + }, + filesystem: { default: false, type: "swap" }, + }, + { + mountPath: "/home", + size: { + min: gib(16), + default: true, + }, + filesystem: { default: false, type: "xfs" }, + }, + ], +}; + +const mockSolvedConfigModel: configModel.Config = { + drives: [mockDrive], +}; + +const homeVolumeMock: Volume = { + mountPath: "/home", + target: VolumeTarget.DEFAULT, + fsType: "Btrfs", + minSize: 1024, + maxSize: 1024, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: ["Btrfs"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [], + adjustByRam: false, + productDefined: false, + }, +}; + +const mockDeleteDrive = jest.fn(); +const mockDeletePartition = jest.fn(); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useAvailableDevices: () => [sda], + useDevices: () => [sda], + useVolume: () => homeVolumeMock, +})); + +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => ({ drives: [mockDrive] }), + useDrive: () => ({ delete: mockDeleteDrive, configuredExistingPartitions: [sda1] }), + usePartition: () => ({ delete: mockDeletePartition }), + useModel: () => ({ unusedMountPaths: ["/home", "swap"], usedMountPaths: [] }), + useSolvedConfigModel: () => mockSolvedConfigModel, +})); + +beforeEach(() => { + mockParams({ id: "sda" }); +}); + +describe("PartitionPage", () => { + it("renders a form for defining a partition", async () => { + const { user } = installerRender(); + screen.getByRole("form", { name: "Define partition at /dev/sda" }); + const mountPoint = screen.getByRole("button", { name: "Mount point toggle" }); + const mountPointMode = screen.getByRole("button", { name: "Mount point mode" }); + const filesystem = screen.getByRole("button", { name: "File system" }); + const size = screen.getByRole("button", { name: "Size" }); + // File system and size fields disabled until valid mount point selected + expect(filesystem).toBeDisabled(); + expect(size).toBeDisabled(); + await user.click(mountPoint); + const mountPointOptions = screen.getByRole("listbox", { name: "Suggested mount points" }); + const homeMountPoint = within(mountPointOptions).getByRole("option", { name: "/home" }); + await user.click(homeMountPoint); + // Valid mount point selected, enable file system and size fields + expect(filesystem).toBeEnabled(); + expect(size).toBeEnabled(); + // Display mount point options + await user.click(mountPointMode); + screen.getByRole("listbox", { name: "Mount point options" }); + // Display available file systems + await user.click(filesystem); + screen.getByRole("listbox", { name: "Available file systems" }); + // Display available size options + await user.click(size); + const sizeOptions = screen.getByRole("listbox", { name: "Size options" }); + // Display custom size + const customSize = within(sizeOptions).getByRole("option", { name: /Custom/ }); + await user.click(customSize); + screen.getByRole("textbox", { name: "Minimum" }); + const maxSizeModeToggle = screen.getByRole("button", { name: "Max size mode" }); + // Do not display input for a maximum size value by default + expect(screen.queryByRole("textbox", { name: "Maximum size value" })).toBeNull(); + await user.click(maxSizeModeToggle); + const maxSizeOptions = screen.getByRole("listbox", { name: "Max size options" }); + const limitedMaxSizeOption = within(maxSizeOptions).getByRole("option", { name: /Limited/ }); + await user.click(limitedMaxSizeOption); + screen.getByRole("textbox", { name: "Maximum size value" }); + }); + + it("allows reseting the chosen mount point", async () => { + const { user } = installerRender(); + // Note that the underline PF component gives the role combobox to the input + const mountPoint = screen.getByRole("combobox", { name: "Mount point" }); + const filesystem = screen.getByRole("button", { name: "File system" }); + const size = screen.getByRole("button", { name: "Size" }); + expect(mountPoint).toHaveValue(""); + // File system and size fields disabled until valid mount point selected + expect(filesystem).toBeDisabled(); + expect(size).toBeDisabled(); + const mountPointToggle = screen.getByRole("button", { name: "Mount point toggle" }); + await user.click(mountPointToggle); + const mountPointOptions = screen.getByRole("listbox", { name: "Suggested mount points" }); + const homeMountPoint = within(mountPointOptions).getByRole("option", { name: "/home" }); + await user.click(homeMountPoint); + expect(mountPoint).toHaveValue("/home"); + expect(filesystem).toBeEnabled(); + expect(size).toBeEnabled(); + const clearMountPointButton = screen.getByRole("button", { + name: "Clear selected mount point", + }); + await user.click(clearMountPointButton); + expect(mountPoint).toHaveValue(""); + // File system and size fields disabled until valid mount point selected + expect(filesystem).toBeDisabled(); + expect(size).toBeDisabled(); + }); +}); diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx new file mode 100644 index 0000000000..815fed3c41 --- /dev/null +++ b/web/src/components/storage/PartitionPage.tsx @@ -0,0 +1,1159 @@ +/* + * 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, { useId } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ActionGroup, + Content, + Divider, + Flex, + FlexItem, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + SelectGroup, + SelectList, + SelectOption, + SelectOptionProps, + Split, + Stack, + TextInput, +} from "@patternfly/react-core"; +import { NestedContent, Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; +import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; +import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; +import { useDevices, useVolume } from "~/queries/storage"; +import { + useModel, + useDrive, + useConfigModel, + useSolvedConfigModel, + addPartition, +} from "~/queries/storage/config-model"; +import { StorageDevice, Volume } from "~/types/storage"; +import { baseName, deviceSize, parseToBytes } from "~/components/storage/utils"; +import { _, formatList } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { configModel } from "~/api/storage/types"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { compact } from "~/utils"; + +const NO_VALUE = ""; +const NEW_PARTITION = "new"; +const BTRFS_SNAPSHOTS = "btrfsSnapshots"; +const REUSE_FILESYSTEM = "reuse"; + +type SizeOptionValue = "" | "auto" | "custom"; +type CustomSizeValue = "fixed" | "unlimited" | "range"; +type FormValue = { + mountPoint: string; + target: string; + filesystem: string; + sizeOption: SizeOptionValue; + minSize: string; + maxSize: string; +}; +type SizeRange = { + min: string; + max: string; +}; +type Error = { + id: string; + message?: string; + isVisible: boolean; +}; + +type ErrorsHandler = { + errors: Error[]; + getError: (id: string) => Error | undefined; + getVisibleError: (id: string) => Error | undefined; +}; + +/** + * @note This type guard is needed because the list of filesystems coming from a volume is not + * enumerated (the volume simply contains a list of strings). This implies we have to rely on + * whatever value coming from such a list as a filesystem type accepted by the config model. + * This will be fixed in the future by directly exporting the volumes as a JSON, similar to the + * config model. The schema for the volumes will define the explicit list of filesystem types. + */ +function isFilesystemType(_value: string): _value is configModel.FilesystemType { + return true; +} + +function partitionConfig(value: FormValue): configModel.Partition { + const name = (): string | undefined => { + if (value.target === NO_VALUE || value.target === NEW_PARTITION) return undefined; + + return value.target; + }; + + const filesystemType = (): configModel.FilesystemType | undefined => { + if (value.filesystem === NO_VALUE) return undefined; + if (value.filesystem === BTRFS_SNAPSHOTS) return "btrfs"; + + const fs_value = value.filesystem.toLowerCase(); + return isFilesystemType(fs_value) ? fs_value : undefined; + }; + + const filesystem = (): configModel.Filesystem | undefined => { + if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true }; + + const type = filesystemType(); + if (type === undefined) return undefined; + + return { + default: false, + type, + snapshots: value.filesystem === BTRFS_SNAPSHOTS, + }; + }; + + const size = (): configModel.Size | undefined => { + if (value.minSize === NO_VALUE) return undefined; + + return { + default: false, + min: parseToBytes(value.minSize), + max: value.maxSize === NO_VALUE ? undefined : parseToBytes(value.maxSize), + }; + }; + + return { + mountPath: value.mountPoint, + name: name(), + filesystem: filesystem(), + size: size(), + }; +} + +function useDevice(): StorageDevice { + const { id } = useParams(); + const devices = useDevices("system", { suspense: true }); + return devices.find((d) => baseName(d.name) === id); +} + +function usePartition(target: string): StorageDevice | null { + const device = useDevice(); + + if (target === NEW_PARTITION) return null; + + const partitions = device.partitionTable?.partitions || []; + return partitions.find((p: StorageDevice) => p.name === target); +} + +function mountPointError(mountPoint: string, assignedPoints: string[]): Error | undefined { + if (mountPoint === NO_VALUE) { + return { + id: "mountPoint", + isVisible: false, + }; + } + + const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; + if (!regex.test(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a valid mount point"), + isVisible: true, + }; + } + + // TODO: exclude itself when editing + if (assignedPoints.includes(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a mount point that is not already assigned to another device"), + isVisible: true, + }; + } +} + +function sizeError(min: string, max: string): Error | undefined { + if (!min) { + return { + id: "customSize", + isVisible: false, + }; + } + + const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/; + const validMin = regexp.test(min); + const validMax = max ? regexp.test(max) : true; + + if (validMin && validMax) { + if (!max || parseToBytes(min) <= parseToBytes(max)) return; + + return { + id: "customSize", + message: _("The minimum cannot be greater than the maximum"), + isVisible: true, + }; + } + + if (validMin) { + return { + id: "customSize", + message: _("The maximum must be a number optionally followed by a unit like GiB or GB"), + isVisible: true, + }; + } + + if (validMax) { + return { + id: "customSize", + message: _("The minimum must be a number optionally followed by a unit like GiB or GB"), + isVisible: true, + }; + } + + return { + id: "customSize", + message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"), + isVisible: true, + }; +} + +function useErrors(value: FormValue): ErrorsHandler { + const { usedMountPaths: assigned } = useModel(); + const size = value.sizeOption === "custom" ? sizeError(value.minSize, value.maxSize) : null; + const errors = compact([mountPointError(value.mountPoint, assigned), size]); + + const getError = (id: string): Error | undefined => errors.find((e) => e.id === id); + + const getVisibleError = (id: string): Error | undefined => { + const error = getError(id); + return error?.isVisible ? error : undefined; + }; + + return { errors, getError, getVisibleError }; +} + +function useSolvedModel(value: FormValue): configModel.Config | null { + const device = useDevice(); + const model = useConfigModel(); + const { errors } = useErrors(value); + const partition = partitionConfig(value); + // Remove size in order to always get a solved size. + partition.size = undefined; + + let sparseModel: configModel.Config | undefined; + + if ( + device && + !errors.length && + value.target === NEW_PARTITION && + value.filesystem !== NO_VALUE && + value.sizeOption !== NO_VALUE + ) { + /** + * @todo Use a specific hook which returns functions like addPartition instead of directly + * exporting the function. For example: + * + * const { model, addPartition } = useEditableModel(); + */ + sparseModel = addPartition(model, device.name, partition); + } + + const solvedModel = useSolvedConfigModel(sparseModel); + return solvedModel; +} + +function useSolvedPartition(value: FormValue): configModel.Partition | undefined { + const model = useSolvedModel(value); + const device = useDevice(); + const drive = model?.drives?.find((d) => d.name === device.name); + return drive?.partitions?.find((p) => p.mountPath === value.mountPoint); +} + +/** @todo include the currently used mount point when editing */ +function mountPointOptions(mountPoints: string[]): SelectOptionProps[] { + return mountPoints.map((p) => ({ value: p, children: p })); +} + +function defaultFilesystem(volume: Volume): string { + return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; +} + +function filesystemOptions(volume: Volume): string[] { + if (volume.mountPath !== "/") return volume.outline.fsTypes; + + if (!volume.outline.snapshotsConfigurable && volume.snapshots) { + // Btrfs without snapshots is not an option + const options = volume.outline.fsTypes.filter((t) => t !== "Btrfs"); + return [BTRFS_SNAPSHOTS, ...options]; + } + + if (!volume.outline.snapshotsConfigurable && !volume.snapshots) { + // Btrfs with snapshots is not an option + return volume.outline.fsTypes; + } + + return [BTRFS_SNAPSHOTS, ...volume.outline.fsTypes]; +} + +type TargetOptionLabelProps = { + value: string; +}; + +function TargetOptionLabel({ value }: TargetOptionLabelProps): React.ReactNode { + const device = useDevice(); + + if (value === NEW_PARTITION) { + return sprintf(_("As a new partition on %s"), device.name); + } else { + return sprintf(_("Using partition %s"), value); + } +} + +type PartitionDescriptionProps = { + partition: StorageDevice; +}; + +function PartitionDescription({ partition }: PartitionDescriptionProps): React.ReactNode { + return ( + + {partition.description} + {deviceSize(partition.size)} + + ); +} + +/** @todo include the currently used partition when editing */ +function TargetOptions(): React.ReactNode { + const device = useDevice(); + const usedPartitions = useDrive(device?.name).configuredExistingPartitions.map((p) => p.name); + const allPartitions = device.partitionTable?.partitions || []; + const partitions = allPartitions.filter((p) => !usedPartitions.includes(p.name)); + + return ( + + + + + + + {partitions.map((partition, index) => ( + } + > + {partition.name} + + ))} + {partitions.length === 0 && ( + {_("There are not usable partitions")} + )} + + + ); +} + +type FilesystemOptionLabelProps = { + value: string; + target: string; +}; + +function FilesystemOptionLabel({ value, target }: FilesystemOptionLabelProps): React.ReactNode { + const partition = usePartition(target); + const filesystem = partition?.filesystem?.type; + if (value === NO_VALUE) return _("Waiting for a mount point"); + // TRANSLATORS: %s is a filesystem type, like Btrfs + if (value === REUSE_FILESYSTEM) return sprintf(_("Current %s"), filesystem); + if (value === BTRFS_SNAPSHOTS) return _("Btrfs with snapshots"); + + return value; +} + +type FilesystemOptionsProps = { + mountPoint: string; + target: string; +}; + +function FilesystemOptions({ mountPoint, target }: FilesystemOptionsProps): React.ReactNode { + const volume = useVolume(mountPoint); + const partition = usePartition(target); + const filesystem = partition?.filesystem?.type; + const defaultOpt = defaultFilesystem(volume); + const options = [defaultOpt].concat(filesystemOptions(volume).filter((o) => o !== defaultOpt)); + const canKeep = !!filesystem && options.includes(filesystem); + + const defaultOptText = volume.mountPath + ? sprintf(_("Default file system for %s"), mountPoint) + : _("Default file system for generic partitions"); + const formatText = filesystem + ? _("Destroy current data and format partition as") + : _("Format partition as"); + + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && canKeep && ( + + + + )} + {mountPoint !== NO_VALUE && canKeep && options.length && } + {mountPoint !== NO_VALUE && ( + + {options.map((fsType, index) => ( + + + + ))} + + )} + + ); +} + +type FilesystemSelectProps = { + id?: string; + value: string; + mountPoint: string; + target: string; + onChange: SelectProps["onChange"]; +}; + +function FilesystemSelect({ + id, + value, + mountPoint, + target, + onChange, +}: FilesystemSelectProps): React.ReactNode { + const usedValue = mountPoint === NO_VALUE ? NO_VALUE : value; + + return ( + } + onChange={onChange} + isDisabled={mountPoint === NO_VALUE} + > + + + ); +} + +type SizeOptionLabelProps = { + value: SizeOptionValue; + mountPoint: string; + target: string; +}; + +function SizeOptionLabel({ value, mountPoint, target }: SizeOptionLabelProps): React.ReactNode { + const partition = usePartition(target); + if (mountPoint === NO_VALUE) return _("Waiting for a mount point"); + if (value === NO_VALUE && target !== NEW_PARTITION) return deviceSize(partition.size); + if (value === "auto") return _("Calculated automatically"); + if (value === "custom") return _("Custom"); + + return value; +} + +type SizeOptionsProps = { + mountPoint: string; + target: string; +}; + +function SizeOptions({ mountPoint, target }: SizeOptionsProps): React.ReactNode { + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && target !== NEW_PARTITION && ( + // TRANSLATORS: %s is a partition name like /dev/vda2 + + + + )} + {mountPoint !== NO_VALUE && target === NEW_PARTITION && ( + <> + + + + + + + > + )} + + ); +} + +type AutoSizeInfoProps = { + mountPoint: string; + partition?: configModel.Partition; +}; + +function AutoSizeTextFallback({ size }) { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %s is a size with units, like "3 GiB" + _("A generic size of %s will be used for the new partition"), + deviceSize(size.min), + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a min size and %2$s is the max, both with units like "3 GiB" + _("A generic size range between %1$s and %2$s will be used for the new partition"), + deviceSize(size.min), + deviceSize(size.max), + ); + } + + return sprintf( + // TRANSLATORS: %s is a size with units, like "3 GiB" + _("A generic minimum size of %s will be used for the new partition"), + deviceSize(size.min), + ); +} + +function AutoSizeTextFixed({ path, size }) { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) + _("A partition of %1$s will be created for %2$s"), + deviceSize(size.min), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a min size, %2$s is the max size and %3$s is a mount path + _("A partition with a size between %1$s and %2$s will be created for %3$s"), + deviceSize(size.min), + deviceSize(size.max), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) + _("A partition of at least %1$s will be created for %2$s"), + deviceSize(size.min), + path, + ); +} + +function AutoSizeTextRam({ path, size }) { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) + _("Based on the amount of RAM in the system, a partition of %1$s will be created for %2$s"), + deviceSize(size.min), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a min size, %2$s is the max size and %3$s is a mount path + _( + "Based on the amount of RAM in the system, a partition with a size between %1$s and %2$s will be created for %3$s", + ), + deviceSize(size.min), + deviceSize(size.max), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) + _( + "Based on the amount of RAM in the system a partition of at least %1$s will be created for %2$s", + ), + deviceSize(size.min), + path, + ); +} + +function AutoSizeTextDynamic({ volume, size }) { + const introText = (volume) => { + const path = volume.mountPath; + const otherPaths = volume.outline.sizeRelevantVolumes || []; + const snapshots = !!volume.outline.snapshotsAffectSizes; + const ram = !!volume.outline.adjustByRam; + + if (ram && snapshots) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + if (otherPaths.length > 1) { + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + return sprintf( + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + return sprintf( + // TRANSLATORS: %s is a mount point (eg. /) + _( + "The size range for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.", + ), + path, + ); + } + + if (ram) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + if (snapshots) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + if (otherPaths.length > 1) { + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + return sprintf( + _( + "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + return sprintf( + // TRANSLATORS: %s is a mount point (eg. /) + _( + "The size range for %s will be dynamically adjusted based on the usage of Btrfs snapshots.", + ), + path, + ); + } + + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + _( + "The size range for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + }; + + const limitsText = (size) => { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %s is a size with units (eg. 10 GiB) + _("The current configuration will result in a partition of %s."), + deviceSize(size.min), + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a min size, %2$s is the max size + _( + "The current configuration will result in a partition with a size between %1$s and %2$s.", + ), + deviceSize(size.min), + deviceSize(size.max), + ); + } + + return sprintf( + // TRANSLATORS: %s is a size with units (eg. 10 GiB) + _("The current configuration will result in a partition of at least %s."), + deviceSize(size.min), + ); + }; + + return ( + <> + {introText(volume)} + {limitsText(size)} + > + ); +} + +function AutoSizeText({ volume, size }) { + const path = volume.mountPath; + + if (path) { + if (volume.autoSize) { + const otherPaths = volume.outline.sizeRelevantVolumes || []; + + if (otherPaths.length || volume.outline.snapshotsAffectSizes) { + return ; + } + + // This assumes volume.autoSize is correctly set. Ie. if it is set to true then at least one + // of the relevant outline fields (snapshots, RAM and sizeRelevantVolumes) is used. + return ; + } + + return ; + } + + // Fallback volume + // This assumes the fallback volume never uses automatic sizes (ie. re-calculated based on + // other aspects of the configuration). It would be VERY surprising if that's the case. + return ; +} + +function AutoSizeInfo({ mountPoint, partition }: AutoSizeInfoProps): React.ReactNode { + const volume = useVolume(mountPoint); + const size = partition?.size; + + if (!size) return; + + return ( + + + + ); +} + +type CustomSizeOptionLabelProps = { + value: CustomSizeValue; +}; + +function CustomSizeOptionLabel({ value }: CustomSizeOptionLabelProps): React.ReactNode { + const labels = { + fixed: _("Same as minimum"), + unlimited: _("None"), + range: _("Limited"), + }; + + return labels[value]; +} + +function CustomSizeOptions(): React.ReactNode { + return ( + + + + + + + + + + + + ); +} + +type CustomSizeProps = { + value: SizeRange; + error: Error; + mountPoint: string; + onChange: (size: SizeRange) => void; +}; + +function CustomSize({ value, error, mountPoint, onChange }: CustomSizeProps) { + const initialOption = (): CustomSizeValue => { + if (value.min === NO_VALUE) return "fixed"; + if (value.min === value.max) return "fixed"; + if (value.max === NO_VALUE) return "unlimited"; + return "range"; + }; + + const [option, setOption] = React.useState(initialOption()); + const volume = useVolume(mountPoint); + + const changeMinSize = (min: string) => { + const max = option === "fixed" ? min : value.max; + onChange({ min, max }); + }; + + const changeMaxSize = (max: string) => { + onChange({ min: value.min, max }); + }; + + const changeOption = (v: CustomSizeValue) => { + setOption(v); + + const min = value.min; + if (v === "fixed") onChange({ min, max: value.min }); + if (v === "unlimited") onChange({ min, max: NO_VALUE }); + if (v === "range") { + const max = volume.maxSize ? deviceSize(volume.maxSize) : NO_VALUE; + onChange({ min, max }); + } + }; + + return ( + + + {_("Sizes must be entered as a numbers optionally followed by a unit.")} + + + {_( + "If the unit is omitted, bytes (B) will be used. Greater units can be of \ + the form GiB (power of 2) or GB (power of 10).", + )} + + + + + + changeMinSize(v)} + /> + + + + + + } + onChange={changeOption} + toggleName={_("Max size mode")} + > + + + {option === "range" && ( + changeMaxSize(v)} + /> + )} + + + + + + + {error && {error.message}} + + + + + ); +} +export default function PartitionPage() { + const headingId = useId(); + const [mountPoint, setMountPoint] = React.useState(NO_VALUE); + const [target, setTarget] = React.useState(NEW_PARTITION); + const [filesystem, setFilesystem] = React.useState(NO_VALUE); + const [sizeOption, setSizeOption] = React.useState(NO_VALUE); + const [minSize, setMinSize] = React.useState(NO_VALUE); + const [maxSize, setMaxSize] = React.useState(NO_VALUE); + const [isReset, setIsReset] = React.useState(false); + + const navigate = useNavigate(); + const device = useDevice(); + const driveConfig = useDrive(device?.name); + const { unusedMountPaths } = useModel(); + + const value = { mountPoint, target, filesystem, sizeOption, minSize, maxSize }; + const solvedPartition = useSolvedPartition(value); + const { errors, getError, getVisibleError } = useErrors(value); + + const volume = useVolume(mountPoint); + const partition = usePartition(target); + + const updateFilesystem = React.useCallback(() => { + const volumeFilesystem = volume ? defaultFilesystem(volume) : NO_VALUE; + const suitableFilesystems = volume?.outline?.fsTypes; + const partitionFilesystem = partition?.filesystem?.type; + + // Reset filesystem if there is no mount point yet. + if (mountPoint === NO_VALUE) setFilesystem(NO_VALUE); + // Select default filesystem for the mount point. + if (mountPoint !== NO_VALUE && target === NEW_PARTITION) setFilesystem(volumeFilesystem); + // Select default filesystem for the mount point if the partition has no filesystem. + if (mountPoint !== NO_VALUE && target !== NEW_PARTITION && !partitionFilesystem) + setFilesystem(volumeFilesystem); + // Reuse the filesystem from the partition if possble. + if (mountPoint !== NO_VALUE && target !== NEW_PARTITION && partitionFilesystem) { + const filesystems = suitableFilesystems || []; + const reuse = filesystems.includes(partitionFilesystem); + setFilesystem(reuse ? REUSE_FILESYSTEM : volumeFilesystem); + } + }, [mountPoint, target, volume, partition, setFilesystem]); + + const updateSizes = React.useCallback( + (sizeOption: SizeOptionValue) => { + if (sizeOption === NO_VALUE || sizeOption === "auto") { + setMinSize(NO_VALUE); + setMaxSize(NO_VALUE); + } else { + const solvedMin = solvedPartition?.size?.min; + const solvedMax = solvedPartition?.size?.max; + const min = solvedMin ? deviceSize(solvedMin) : NO_VALUE; + const max = solvedMax ? deviceSize(solvedMax) : NO_VALUE; + setMinSize(min); + setMaxSize(max); + } + }, + [solvedPartition, setMinSize, setMaxSize], + ); + + React.useEffect(() => { + if (isReset) { + setIsReset(false); + setFilesystem(NO_VALUE); + setSizeOption(NO_VALUE); + setMinSize(NO_VALUE); + setMaxSize(NO_VALUE); + + const mountPointError = getError("mountPoint"); + if (!mountPointError && target === NEW_PARTITION) setSizeOption("auto"); + if (!mountPointError) updateFilesystem(); + } + }, [ + mountPoint, + target, + isReset, + setFilesystem, + setSizeOption, + setMinSize, + setMaxSize, + updateFilesystem, + getError, + ]); + + const changeMountPoint = (value: string) => { + if (value !== mountPoint) { + setMountPoint(value); + setIsReset(true); + } + }; + + const changeTarget = (value: string) => { + setTarget(value); + setIsReset(true); + }; + + const changeFilesystem = (value: string) => { + setFilesystem(value); + setSizeOption("auto"); + }; + + /** + * @note The CustomSize component initializes its state based on the sizes passed as prop in the + * first render. It is important to set the correct sizes before changing the size option to + * custom. + */ + const changeSizeOption = (value: SizeOptionValue) => { + updateSizes(value); + setSizeOption(value); + }; + + const changeSize = ({ min, max }) => { + if (min !== undefined) setMinSize(min); + if (max !== undefined) setMaxSize(max); + }; + + const onSubmit = () => { + driveConfig.addPartition(partitionConfig(value)); + navigate(PATHS.root); + }; + + const isFormValid = errors.length === 0; + const mountPointError = getVisibleError("mountPoint"); + const customSizeError = getVisibleError("customSize"); + const usedMountPt = mountPointError ? NO_VALUE : mountPoint; + + return ( + + + + {sprintf(_("Define partition at %s"), device.name)} + + + + + + + + + + + + + } + onChange={changeTarget} + > + + + + + + + + {!mountPointError && _("Select or enter a mount point")} + {mountPointError?.message} + + + + + + + + + + + } + onChange={changeSizeOption} + isDisabled={usedMountPt === NO_VALUE} + > + + + + {target === NEW_PARTITION && sizeOption === "auto" && ( + + )} + {target === NEW_PARTITION && sizeOption === "custom" && ( + + )} + + + + + + + + + + + + ); +} diff --git a/web/src/components/storage/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx index 1b65f8b416..276a079a24 100644 --- a/web/src/components/storage/ProposalFailedInfo.tsx +++ b/web/src/components/storage/ProposalFailedInfo.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Alert, Content } from "@patternfly/react-core"; import { _, n_, formatList } from "~/i18n"; import { useIssues } from "~/queries/issues"; -import { useConfigModel } from "~/queries/storage"; +import { useConfigModel } from "~/queries/storage/config-model"; import { IssueSeverity } from "~/types/issues"; import * as partitionUtils from "~/components/storage/utils/partition"; import { sprintf } from "sprintf-js"; @@ -34,7 +34,7 @@ function Description({ partitions }) { if (!newPartitions.length) { return ( - + {_( "It is not possible to install the system with the current configuration. Adjust the settings below.", )} @@ -57,8 +57,8 @@ function Description({ partitions }) { return ( <> - {msg1} - + {msg1} + {_("Adjust the settings below to make the new system fit into the available space.")} > 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/ProposalResultSection.test.tsx b/web/src/components/storage/ProposalResultSection.test.tsx index a58da722fe..4dd2e1fb2e 100644 --- a/web/src/components/storage/ProposalResultSection.test.tsx +++ b/web/src/components/storage/ProposalResultSection.test.tsx @@ -31,11 +31,15 @@ const mockConfig = { drives: [] }; jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), - useConfigModel: () => mockConfig, useDevices: () => devices.staging, useActions: () => mockUseActionsFn(), })); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockConfig, +})); + describe("ProposalResultSection", () => { beforeEach(() => { mockUseActionsFn.mockReturnValue(actions); @@ -110,7 +114,7 @@ describe("ProposalResultSection", () => { within(treegrid).getByRole("row", { name: "vdc5 / Btrfs Partition 17.5 GiB" }); }); - it("renders a button for opening the planned actions dialog", async () => { + it("allows toggling the planned actions", async () => { const { user } = plainRender(); const button = screen.getByRole("button", { name: /planned actions/ }); diff --git a/web/src/components/storage/ProposalResultTable.tsx b/web/src/components/storage/ProposalResultTable.tsx index 2604284a0b..9352122d71 100644 --- a/web/src/components/storage/ProposalResultTable.tsx +++ b/web/src/components/storage/ProposalResultTable.tsx @@ -36,7 +36,7 @@ import { deviceChildren, deviceSize } from "~/components/storage/utils"; import { PartitionSlot, StorageDevice } from "~/types/storage"; import { TreeTableColumn } from "~/components/core/TreeTable"; import { DeviceInfo } from "~/api/storage/types"; -import { useConfigModel } from "~/queries/storage"; +import { useConfigModel } from "~/queries/storage/config-model"; type TableItem = StorageDevice | PartitionSlot; 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/utils.test.ts b/web/src/components/storage/utils.test.ts index d549b90bfb..cc1e091ab6 100644 --- a/web/src/components/storage/utils.test.ts +++ b/web/src/components/storage/utils.test.ts @@ -241,6 +241,15 @@ describe("parseToBytes", () => { it("does not include decimal part of resulting conversion", () => { expect(parseToBytes("1024.32 KiB")).toEqual(1048903); // Not 1048903.68 }); + + it("always considers a downcase 'b' as bytes (like Y2Storage)", () => { + expect(parseToBytes("1 KiB")).toEqual(1024); + expect(parseToBytes("1 Kib")).toEqual(1024); + expect(parseToBytes("1 kib")).toEqual(1024); + expect(parseToBytes("1 kIb")).toEqual(1024); + expect(parseToBytes("1 KIb")).toEqual(1024); + expect(parseToBytes("1 KIB")).toEqual(1024); + }); }); describe("splitSize", () => { diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index 91a8633e42..559f0cfd14 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -67,6 +67,26 @@ const SIZE_UNITS = Object.freeze({ P: N_("PiB"), }); +const FILESYSTEM_NAMES = Object.freeze({ + bcachefs: N_("Bcachefs"), + bitlocke: N_("BitLocker"), + btrfs: N_("Btrfs"), + exfat: N_("ExFAT"), + ext2: N_("Ext2"), + ext3: N_("Ext3"), + ext4: N_("Ext4"), + f2fs: N_("F2FS"), + jfs: N_("JFS"), + nfs: N_("NFS"), + nilfs2: N_("NILFS2"), + ntfs: N_("NTFS"), + reiserfs: N_("ReiserFS"), + swap: N_("Swap"), + tmpfs: N_("Tmpfs"), + vfat: N_("FAT"), + xfs: N_("XFS"), +}); + const DEFAULT_SIZE_UNIT = "GiB"; const SPACE_POLICIES: SpacePolicy[] = [ @@ -153,7 +173,8 @@ const deviceSize = (size: number): string => { const parseToBytes = (size: string | number): number => { if (!size || size === undefined || size === "") return 0; - const value = xbytes.parseSize(size.toString(), { iec: true }) || parseInt(size.toString()); + const value = + xbytes.parseSize(size.toString().toUpperCase(), { iec: true }) || parseInt(size.toString()); // Avoid decimals resulting from the conversion. D-Bus iface only accepts integer return Math.trunc(value); @@ -257,6 +278,34 @@ const reuseDevice = (volume: Volume): boolean => const volumeLabel = (volume: Volume): string => volume.mountPath === "/" ? "root" : volume.mountPath; +/** + * @see filesystemType + */ +const filesystemName = (fstype: string): string => { + const name = FILESYSTEM_NAMES[fstype]; + + // eslint-disable-next-line agama-i18n/string-literals + if (name) return _(name); + + // Fallback for unknown filesystem types + return fstype.charAt(0).toUpperCase() + fstype.slice(1); +}; + +/** + * String to represent the filesystem type + * + * @returns undefined if there is not enough information + */ +const filesystemType = (filesystem: configModel.Filesystem): string | undefined => { + if (filesystem.type) { + if (filesystem.snapshots) return _("Btrfs with snapshots"); + + return filesystemName(filesystem.type); + } + + return undefined; +}; + /** * GiB to Bytes. */ @@ -297,6 +346,7 @@ export { deviceLabel, deviceChildren, deviceSize, + filesystemType, formattedPath, gib, parseToBytes, diff --git a/web/src/components/storage/utils/partition.tsx b/web/src/components/storage/utils/partition.tsx index 4dd2d6b35d..8dff68878b 100644 --- a/web/src/components/storage/utils/partition.tsx +++ b/web/src/components/storage/utils/partition.tsx @@ -24,7 +24,7 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { formattedPath, sizeDescription } from "~/components/storage/utils"; +import { filesystemType, formattedPath, sizeDescription } from "~/components/storage/utils"; import { configModel } from "~/api/storage/types"; /** @@ -40,4 +40,38 @@ const pathWithSize = (partition: configModel.Partition): string => { ); }; -export { pathWithSize }; +/** + * String to identify the type of partition to be created (or used). + */ +const typeDescription = (partition: configModel.Partition): string => { + const fs = filesystemType(partition.filesystem); + + if (partition.name) { + if (partition.filesystem.reuse) { + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a device name (eg. /dev/sda3). + if (fs) return sprintf(_("Current %1$s at %2$s"), fs, partition.name); + + // TRANSLATORS: %s is a device name (eg. /dev/sda3). + return sprintf(_("Current %s"), partition.name); + } + + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a device name (eg. /dev/sda3). + return sprintf(_("%1$s at %2$s"), fs, partition.name); + } + return fs; +}; + +/** + * Combination of {@link typeDescription} and the size of the target partition. + */ +const typeWithSize = (partition: configModel.Partition): string => { + return sprintf( + // TRANSLATORS: %1$s is a filesystem type description (eg. "Btrfs with snapshots"), + // %2$s is a description of the size or the size limits (eg. "at least 10 GiB") + _("%1$s (%2$s)"), + typeDescription(partition), + sizeDescription(partition.size), + ); +}; + +export { pathWithSize, typeDescription, typeWithSize }; 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/queries/storage.ts b/web/src/queries/storage.ts index 47d916c022..9068153cd6 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -20,27 +20,21 @@ * find current contact information at www.suse.com. */ -import { - useMutation, - useQuery, - useQueryClient, - useSuspenseQueries, - useSuspenseQuery, -} from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import React from "react"; import { fetchConfig, setConfig, fetchActions, - fetchDefaultVolume, + fetchVolumeTemplates, fetchProductParams, fetchUsableDevices, reprobe, } from "~/api/storage"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { useInstallerClient } from "~/context/installer"; -import { config, ProductParams, Volume as APIVolume } from "~/api/storage/types"; -import { Action, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; +import { config, ProductParams } from "~/api/storage/types"; +import { Action, StorageDevice, Volume } from "~/types/storage"; import { QueryHookOptions } from "~/types/queries"; @@ -68,36 +62,10 @@ const productParamsQuery = { staleTime: Infinity, }; -const defaultVolumeQuery = (mountPath: string) => ({ - queryKey: ["storage", "volumeFor", mountPath], - queryFn: () => fetchDefaultVolume(mountPath), +const volumeTemplatesQuery = { + queryKey: ["storage", "volumeTemplates"], + queryFn: fetchVolumeTemplates, staleTime: Infinity, -}); - -/** - * @private - * Builds a volume from the D-Bus data - */ -const buildVolume = ( - rawVolume: APIVolume, - devices: StorageDevice[], - productMountPoints: string[], -): Volume => { - const outline = { - ...rawVolume.outline, - // Indicate whether a volume is defined by the product. - productDefined: productMountPoints.includes(rawVolume.mountPath), - }; - const volume: Volume = { - ...rawVolume, - outline, - minSize: rawVolume.minSize || 0, - transactional: rawVolume.transactional || false, - target: rawVolume.target as VolumeTarget, - targetDevice: devices.find((d) => d.name === rawVolume.targetDevice), - }; - - return volume; }; /** @@ -170,22 +138,17 @@ const useProductParams = (options?: QueryHookOptions): ProductParams => { * Hook that returns the volume templates for the current product. */ const useVolumeTemplates = (): Volume[] => { - const buildDefaultVolumeQueries = (product: ProductParams) => { - const queries = product.mountPoints.map((p) => defaultVolumeQuery(p)); - queries.push(defaultVolumeQuery("")); - return queries; - }; - - const systemDevices = useDevices("system", { suspense: true }); - const product = useProductParams(); - const results = useSuspenseQueries({ - queries: product ? buildDefaultVolumeQueries(product) : [], - }) as Array<{ data: APIVolume }>; + const { data } = useSuspenseQuery(volumeTemplatesQuery); + return data; +}; - if (results.length === 0) return []; +function useVolume(mountPoint: string): Volume { + const volumes = useVolumeTemplates(); + const volume = volumes.find((v) => v.mountPath === mountPoint); + const defaultVolume = volumes.find((v) => v.mountPath === ""); - return results.map(({ data }) => buildVolume(data, systemDevices, product.mountPoints)); -}; + return volume || defaultVolume; +} /** * Hook that returns the devices that can be selected as target for volume. @@ -282,11 +245,10 @@ export { useAvailableDevices, useProductParams, useVolumeTemplates, + useVolume, useVolumeDevices, useActions, useDeprecated, useDeprecatedChanges, useReprobeMutation, }; - -export * from "~/queries/storage/config-model"; diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index a75aa545e1..9644afc2b0 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -21,10 +21,11 @@ */ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchConfigModel, setConfigModel } from "~/api/storage"; +import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; import { configModel } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; -import { SpacePolicyAction } from "~/types/storage"; +import { SpacePolicyAction, Volume } from "~/types/storage"; +import { useVolumeTemplates } from "~/queries/storage"; function copyModel(model: configModel.Config): configModel.Config { return JSON.parse(JSON.stringify(model)); @@ -47,7 +48,7 @@ function isReusedPartition(partition: configModel.Partition): boolean { } function findDrive(model: configModel.Config, driveName: string): configModel.Drive | undefined { - const drives = model.drives || []; + const drives = model?.drives || []; return drives.find((d) => d.name === driveName); } @@ -83,6 +84,23 @@ function isExplicitBoot(model: configModel.Config, driveName: string): boolean { return !model.boot?.device?.default && driveName === model.boot?.device?.name; } +function allMountPaths(drive: configModel.Drive): string[] { + if (drive.mountPath) return [drive.mountPath]; + + return drive.partitions.map((p) => p.mountPath).filter((m) => m); +} + +function configuredExistingPartitions(drive: configModel.Drive): configModel.Partition[] { + const allPartitions = drive.partitions || []; + + if (drive.spacePolicy === "custom") + return allPartitions.filter( + (p) => !isNewPartition(p) && (isUsedPartition(p) || isSpacePartition(p)), + ); + + return allPartitions.filter((p) => isReusedPartition(p)); +} + function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { const model = copyModel(originalModel); const name = model.boot?.device?.name; @@ -131,6 +149,25 @@ function deletePartition( return model; } +/** Adds a new partition or replaces an existing partition. */ +export function addPartition( + originalModel: configModel.Config, + driveName: string, + partition: configModel.Partition, +): configModel.Config { + const model = copyModel(originalModel); + const drive = findDrive(model, driveName); + if (drive === undefined) return; + + drive.partitions ||= []; + const index = drive.partitions.findIndex((p) => p.name && p.name === partition.name); + + if (index === -1) drive.partitions.push(partition); + else drive.partitions[index] = partition; + + return model; +} + function switchDrive( originalModel: configModel.Config, driveName: string, @@ -236,6 +273,18 @@ function setSpacePolicy( return model; } +function usedMountPaths(model: configModel.Config): string[] { + if (!model.drives) return []; + + return model.drives.flatMap(allMountPaths); +} + +function unusedMountPaths(model: configModel.Config, volumes: Volume[]): string[] { + const volPaths = volumes.filter((v) => v.mountPath.length).map((v) => v.mountPath); + const assigned = usedMountPaths(model); + return volPaths.filter((p) => !assigned.includes(p)); +} + const configModelQuery = { queryKey: ["storage", "configModel"], queryFn: fetchConfigModel, @@ -265,6 +314,20 @@ export function useConfigModelMutation() { return useMutation(query); } +/** + * @todo Use a hash key from the model object as id for the query. + * Hook that returns the config model. + */ +export function useSolvedConfigModel(model?: configModel.Config): configModel.Config | null { + const query = useSuspenseQuery({ + queryKey: ["storage", "solvedConfigModel", JSON.stringify(model)], + queryFn: () => (model ? solveConfigModel(model) : Promise.resolve(null)), + staleTime: Infinity, + }); + + return query.data; +} + export type BootHook = { configure: boolean; isDefault: boolean; @@ -310,40 +373,54 @@ export function usePartition(driveName: string, mountPath: string): PartitionHoo export type DriveHook = { isBoot: boolean; isExplicitBoot: boolean; + allMountPaths: string[]; + configuredExistingPartitions: configModel.Partition[]; switch: (newName: string) => void; + addPartition: (partition: configModel.Partition) => void; setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => void; delete: () => void; }; -export function useDrive(name: string): DriveHook | undefined { +export function useDrive(name: string): DriveHook | null { const model = useConfigModel(); const { mutate } = useConfigModelMutation(); + const drive = findDrive(model, name); - if (findDrive(model, name) === undefined) return; + if (drive === undefined) return null; return { isBoot: isBoot(model, name), isExplicitBoot: isExplicitBoot(model, name), + allMountPaths: allMountPaths(drive), + configuredExistingPartitions: configuredExistingPartitions(drive), switch: (newName) => mutate(switchDrive(model, name, newName)), - setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => { - mutate(setSpacePolicy(model, name, policy, actions)); - }, delete: () => mutate(removeDrive(model, name)), + addPartition: (partition: configModel.Partition) => + mutate(addPartition(model, name, partition)), + setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => + mutate(setSpacePolicy(model, name, policy, actions)), }; } -/** - * Hook for operating on the collections of the model. - */ export type ModelHook = { + model: configModel.Config; + usedMountPaths: string[]; + unusedMountPaths: string[]; addDrive: (driveName: string) => void; }; +/** + * Hook for operating on the collections of the model. + */ export function useModel(): ModelHook { const model = useConfigModel(); const { mutate } = useConfigModelMutation(); + const volumes = useVolumeTemplates(); return { + model, addDrive: (driveName) => mutate(addDrive(model, driveName)), + usedMountPaths: model ? usedMountPaths(model) : [], + unusedMountPaths: model ? unusedMountPaths(model, volumes) : [], }; } diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 1ccb0726c8..7b51026490 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(); }, }, diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 962ceafaf4..1cdf7db152 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -29,7 +29,7 @@ */ import React from "react"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, useParams } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { render } from "@testing-library/react"; @@ -44,6 +44,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; */ const initialRoutes = jest.fn().mockReturnValue(["/"]); +/** + * Internal mock for manipulating params + */ +let paramsMock: ReturnType = {}; + /** * Allows checking when react-router-dom navigate function was * called with certain path @@ -73,12 +78,21 @@ const mockUseRevalidator = jest.fn(); */ const mockRoutes = (...routes) => initialRoutes.mockReturnValueOnce(routes); +/** + * Allows mocking useParams react-router-dom hook for testing purpose + * + * @example + * mockParams({ id: "vda" }); + */ +const mockParams = (params: ReturnType) => (paramsMock = params); + // Centralize the react-router-dom mock here jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useHref: (to) => to, useNavigate: () => mockNavigateFn, useMatches: () => [], + useParams: () => paramsMock, Navigate: ({ to: route }) => <>Navigating to {route}>, Outlet: () => <>Outlet Content>, useRevalidator: () => mockUseRevalidator, @@ -196,6 +210,7 @@ export { installerRender, createCallbackMock, mockNavigateFn, + mockParams, mockRoutes, mockUseRevalidator, resetLocalStorage,
{description}