From 071b7ab7b25878fefc3b695e62ac7936309ff10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 22 Apr 2024 14:59:58 +0100 Subject: [PATCH 01/18] web: Indicate if a volume is defined by product --- web/src/client/storage.js | 25 +++++++++++++++++++------ web/src/client/storage.test.js | 17 ++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 190600f862..a70775db1d 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -150,6 +150,7 @@ const ZFCP_DISK_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Disk"; * * @typedef {object} VolumeOutline * @property {boolean} required + * @property {boolean} productDefined * @property {string[]} fsTypes * @property {boolean} adjustByRam * @property {boolean} supportAutoSize @@ -464,7 +465,8 @@ class ProposalManager { async defaultVolume(mountPath) { const proxy = await this.proxies.proposalCalculator; const systemDevices = await this.system.getDevices(); - return this.buildVolume(await proxy.DefaultVolume(mountPath), systemDevices); + const productMountPoints = await this.getProductMountPoints(); + return this.buildVolume(await proxy.DefaultVolume(mountPath), systemDevices, productMountPoints); } /** @@ -478,6 +480,7 @@ class ProposalManager { if (!proxy) return undefined; const systemDevices = await this.system.getDevices(); + const productMountPoints = await this.getProductMountPoints(); const buildResult = (proxy) => { const buildSpaceAction = dbusSpaceAction => { @@ -556,7 +559,9 @@ class ProposalManager { spaceActions: dbusSettings.SpaceActions.v.map(a => buildSpaceAction(a.v)), encryptionPassword: dbusSettings.EncryptionPassword.v, encryptionMethod: dbusSettings.EncryptionMethod.v, - volumes: dbusSettings.Volumes.v.map(vol => this.buildVolume(vol.v, systemDevices)), + volumes: dbusSettings.Volumes.v.map(vol => ( + this.buildVolume(vol.v, systemDevices, productMountPoints)) + ), // NOTE: strictly speaking, installation devices does not belong to the settings. It // should be a separate method instead of an attribute in the settings object. // Nevertheless, it was added here for simplicity and to avoid passing more props in some @@ -639,6 +644,8 @@ class ProposalManager { * Builds a volume from the D-Bus data * * @param {DBusVolume} dbusVolume + * @param {StorageDevice[]} devices + * @param {string[]} productMountPoints * * @typedef {Object} DBusVolume * @property {CockpitString} Target @@ -684,7 +691,7 @@ class ProposalManager { * * @returns {Volume} */ - buildVolume(dbusVolume, devices) { + buildVolume(dbusVolume, devices, productMountPoints) { /** * Builds a volume target from a D-Bus value. * @@ -704,11 +711,11 @@ class ProposalManager { } }; + /** @returns {VolumeOutline} */ const buildOutline = (dbusOutline) => { - if (dbusOutline === undefined) return null; - return { required: dbusOutline.Required.v, + productDefined: false, fsTypes: dbusOutline.FsTypes.v.map(val => val.v), supportAutoSize: dbusOutline.SupportAutoSize.v, adjustByRam: dbusOutline.AdjustByRam.v, @@ -718,7 +725,7 @@ class ProposalManager { }; }; - return { + const volume = { target: buildTarget(dbusVolume.Target.v), targetDevice: devices.find(d => d.name === dbusVolume.TargetDevice?.v), mountPath: dbusVolume.MountPath.v, @@ -730,6 +737,12 @@ class ProposalManager { transactional: dbusVolume.Transactional.v, outline: buildOutline(dbusVolume.Outline.v) }; + + // Indicate whether a volume is defined by the product. + if (productMountPoints.includes(volume.mountPath)) + volume.outline.productDefined = true; + + return volume; } /** diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index c7bd15499c..86e6129a9e 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -1408,6 +1408,7 @@ describe("#proposal", () => { describe("#defaultVolume", () => { beforeEach(() => { + cockpitProxies.proposalCalculator.ProductMountPoints = ["/", "swap", "/home"]; cockpitProxies.proposalCalculator.DefaultVolume = jest.fn(mountPath => { switch (mountPath) { case "/home": return { @@ -1482,7 +1483,8 @@ describe("#proposal", () => { snapshotsConfigurable: false, snapshotsAffectSizes: false, adjustByRam: false, - sizeRelevantVolumes: [] + sizeRelevantVolumes: [], + productDefined: true } }); @@ -1505,7 +1507,8 @@ describe("#proposal", () => { snapshotsConfigurable: false, snapshotsAffectSizes: false, adjustByRam: false, - sizeRelevantVolumes: [] + sizeRelevantVolumes: [], + productDefined: false } }); }); @@ -1528,10 +1531,12 @@ describe("#proposal", () => { beforeEach(() => { contexts.withSystemDevices(); contexts.withProposal(); - client = new StorageClient(); + cockpitProxies.proposalCalculator.ProductMountPoints = ["/", "swap"]; }); it("returns the proposal settings and actions", async () => { + client = new StorageClient(); + const { settings, actions } = await client.proposal.getResult(); expect(settings).toMatchObject({ @@ -1563,7 +1568,8 @@ describe("#proposal", () => { supportAutoSize: true, snapshotsConfigurable: true, snapshotsAffectSizes: true, - sizeRelevantVolumes: ["/home"] + sizeRelevantVolumes: ["/home"], + productDefined: true } }, { @@ -1582,7 +1588,8 @@ describe("#proposal", () => { supportAutoSize: false, snapshotsConfigurable: false, snapshotsAffectSizes: false, - sizeRelevantVolumes: [] + sizeRelevantVolumes: [], + productDefined: false } } ] From b3d863d943ba8e341afc967a1d681ed5f24494e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 22 Apr 2024 15:01:06 +0100 Subject: [PATCH 02/18] web: Do not consider boot device if not needed --- web/src/client/storage.js | 3 ++- web/src/client/storage.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/web/src/client/storage.js b/web/src/client/storage.js index a70775db1d..1eba9b9ff3 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -535,10 +535,11 @@ class ProposalManager { const values = [ dbusSettings.TargetDevice?.v, buildTargetPVDevices(dbusSettings.TargetPVDevices), - dbusSettings.BootDevice.v, dbusSettings.Volumes.v.map(vol => vol.v.TargetDevice.v) ].flat(); + if (dbusSettings.ConfigureBoot.v) values.push(dbusSettings.BootDevice.v); + const names = uniq(compact(values)).filter(d => d.length > 0); // #findDevice returns undefined if no device is found with the given name. diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index 86e6129a9e..b8d123af3a 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -1603,6 +1603,19 @@ describe("#proposal", () => { { device: 2, text: "Mount /dev/sdb1 as root", subvol: false, delete: false } ]); }); + + describe("if boot is not configured", () => { + beforeEach(() => { + cockpitProxies.proposal.Settings.ConfigureBoot = { t: "b", v: false }; + cockpitProxies.proposal.Settings.BootDevice = { t: "s", v: "/dev/sdc" }; + }); + + it("does not include the boot device as installation device", async () => { + client = new StorageClient(); + const { settings } = await client.proposal.getResult(); + expect(settings.installationDevices).toEqual([sda, sdb]); + }); + }); }); }); From b506ad841d5ec418e98ed8cfbb7645d45ea79a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 22 Apr 2024 15:02:01 +0100 Subject: [PATCH 03/18] web: Do not send boot device if not needed --- web/src/components/storage/BootSelectionDialog.jsx | 2 +- web/src/components/storage/BootSelectionDialog.test.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/BootSelectionDialog.jsx b/web/src/components/storage/BootSelectionDialog.jsx index ea043eb29e..006cb54d9e 100644 --- a/web/src/components/storage/BootSelectionDialog.jsx +++ b/web/src/components/storage/BootSelectionDialog.jsx @@ -104,7 +104,7 @@ export default function BootSelectionDialog({ const onSubmit = (e) => { e.preventDefault(); - const device = isBootAuto ? undefined : bootDevice; + const device = ((configureBoot && !isBootAuto) ? bootDevice : undefined); onAccept({ configureBoot, bootDevice: device }); }; diff --git a/web/src/components/storage/BootSelectionDialog.test.jsx b/web/src/components/storage/BootSelectionDialog.test.jsx index 395c0e4ebb..4b3a80fe39 100644 --- a/web/src/components/storage/BootSelectionDialog.test.jsx +++ b/web/src/components/storage/BootSelectionDialog.test.jsx @@ -225,7 +225,7 @@ describe("BootSelectionDialog", () => { describe("if the 'Do not configure' option is selected", () => { beforeEach(() => { props.configureBoot = true; - props.bootDevice = undefined; + props.bootDevice = sda; }); it("calls onAccept with the selected options on accept", async () => { From 9ca19c125cc47426b840a6381a477caff2e00f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 22 Apr 2024 15:04:53 +0100 Subject: [PATCH 04/18] web: Fix tests --- web/src/components/storage/PartitionsField.test.jsx | 9 ++++++--- web/src/components/storage/ProposalPage.test.jsx | 3 ++- web/src/components/storage/SnapshotsField.test.jsx | 3 ++- web/src/components/storage/VolumeLocationDialog.test.jsx | 3 ++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/components/storage/PartitionsField.test.jsx b/web/src/components/storage/PartitionsField.test.jsx index 1ca64e36e8..02398cc14a 100644 --- a/web/src/components/storage/PartitionsField.test.jsx +++ b/web/src/components/storage/PartitionsField.test.jsx @@ -58,7 +58,8 @@ const rootVolume = { snapshotsConfigurable: true, snapshotsAffectSizes: true, sizeRelevantVolumes: [], - adjustByRam: false + adjustByRam: false, + productDefined: true } }; @@ -79,7 +80,8 @@ const swapVolume = { snapshotsConfigurable: false, snapshotsAffectSizes: false, sizeRelevantVolumes: [], - adjustByRam: false + adjustByRam: false, + productDefined: true } }; @@ -99,7 +101,8 @@ const homeVolume = { snapshotsConfigurable: false, snapshotsAffectSizes: false, sizeRelevantVolumes: [], - adjustByRam: false + adjustByRam: false, + productDefined: true } }; diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index 00649c0bf0..661223c847 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -115,7 +115,8 @@ const volume = (mountPath) => { snapshotsConfigurable: false, snapshotsAffectSizes: false, sizeRelevantVolumes: [], - adjustByRam: false + adjustByRam: false, + productDefined: false } } ); diff --git a/web/src/components/storage/SnapshotsField.test.jsx b/web/src/components/storage/SnapshotsField.test.jsx index 4b2bb43ae5..9516843a77 100644 --- a/web/src/components/storage/SnapshotsField.test.jsx +++ b/web/src/components/storage/SnapshotsField.test.jsx @@ -47,7 +47,8 @@ const rootVolume = { snapshotsConfigurable: false, snapshotsAffectSizes: true, adjustByRam: false, - sizeRelevantVolumes: ["/home"] + sizeRelevantVolumes: ["/home"], + productDefined: true } }; diff --git a/web/src/components/storage/VolumeLocationDialog.test.jsx b/web/src/components/storage/VolumeLocationDialog.test.jsx index e89367209e..81c4cc823d 100644 --- a/web/src/components/storage/VolumeLocationDialog.test.jsx +++ b/web/src/components/storage/VolumeLocationDialog.test.jsx @@ -118,7 +118,8 @@ const volume = { snapshotsConfigurable: true, snapshotsAffectSizes: true, sizeRelevantVolumes: [], - adjustByRam: false + adjustByRam: false, + productDefined: true } }; From 22c212664860c94d9aa5d9227fc85f184d57fac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 22 Apr 2024 15:06:45 +0100 Subject: [PATCH 05/18] web: Add VolumeDialog - Missing unit tests. --- web/src/components/storage/VolumeDialog.jsx | 752 ++++++++++++++++++ ...umeForm.test.jsx => VolumeDialog.test.jsx} | 2 + .../{VolumeForm.jsx => VolumeFields.jsx} | 438 +++------- web/src/components/storage/index.js | 1 - 4 files changed, 858 insertions(+), 335 deletions(-) create mode 100644 web/src/components/storage/VolumeDialog.jsx rename web/src/components/storage/{VolumeForm.test.jsx => VolumeDialog.test.jsx} (99%) rename web/src/components/storage/{VolumeForm.jsx => VolumeFields.jsx} (54%) diff --git a/web/src/components/storage/VolumeDialog.jsx b/web/src/components/storage/VolumeDialog.jsx new file mode 100644 index 0000000000..ece1a63435 --- /dev/null +++ b/web/src/components/storage/VolumeDialog.jsx @@ -0,0 +1,752 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React, { useReducer } from "react"; +import { Button, Form, FormGroup } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + +import { _ } from "~/i18n"; +import { compact, useDebounce } from "~/utils"; +import { + DEFAULT_SIZE_UNIT, SIZE_METHODS, parseToBytes, splitSize +} from '~/components/storage/utils'; +import { FsField, MountPathField, SizeOptionsField } from "~/components/storage/VolumeFields"; +import { If, Popup } from '~/components/core'; + +/** + * @typedef {import ("~/client/storage").Volume} Volume + * @typedef {import("~/components/storage/utils").SizeMethod} SizeMethod + * + * @typedef {object} VolumeFormState + * @property {Volume} volume + * @property {VolumeFormData} formData + * @property {VolumeFormErrors} errors + * + * @typedef {object} VolumeFormData + * @property {number|string} [size] + * @property {string} [sizeUnit] + * @property {number|string} [minSize] + * @property {string} [minSizeUnit] + * @property {number|string} [maxSize] + * @property {string} [maxSizeUnit] + * @property {SizeMethod} sizeMethod + * @property {string} mountPath + * @property {string} fsType + * @property {boolean} snapshots + * + * @typedef {object} VolumeFormErrors + * @property {string|null} missingMountPath + * @property {string|null} invalidMountPath + * @property {React.ReactElement|null} existingVolume + * @property {React.ReactElement|null} existingTemplate + * @property {string|null} missingSize + * @property {string|null} missingMinSize + * @property {string|null} invalidMaxSize + */ + +/** + * Renders the title for the dialog. + * @function + * + * @param {Volume} volume + * @param {Volume[]} volumes + * @returns {string} + */ +const renderTitle = (volume, volumes) => { + const isNewVolume = !volumes.includes(volume); + const isProductDefined = volume.outline.productDefined; + const mountPath = volume.mountPath === "/" ? "root" : volume.mountPath; + + if (isNewVolume && isProductDefined) return sprintf(_("Add %s file system"), mountPath); + if (!isNewVolume && isProductDefined) return sprintf(_("Edit %s file system"), mountPath); + + return isNewVolume ? _("Add file system") : _("Edit file system"); +}; + +class MissingMountPathError { + /** + * @constructor + * @param {string} mountPath + */ + constructor(mountPath) { + this.mountPath = mountPath; + } + + /** + * @method + * @returns {boolean} + */ + check() { + return this.mountPath.length === 0; + } + + /** + * @method + * @returns {String} + */ + render() { + return _("A mount point is required"); + } +} + +class InvalidMountPathError { + /** + * @constructor + * @param {string} mountPath + */ + constructor(mountPath) { + this.mountPath = mountPath; + } + + /** + * @method + * @returns {boolean} + */ + check() { + const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; + return !regex.test(this.mountPath); + } + + /** + * @method + * @returns {string} + */ + render() { + return _("The mount point is invalid"); + } +} + +class MissingSizeError { + /** + * @constructor + * @param {SizeMethod} sizeMethod + * @param {string|number} size + */ + constructor (sizeMethod, size) { + this.sizeMethod = sizeMethod; + this.size = size; + } + + /** + * @method + * @returns {boolean} + */ + check() { + return this.sizeMethod === SIZE_METHODS.MANUAL && !this.size; + } + + /** + * @method + * @returns {string} + */ + render() { + return _("A size value is required"); + } +} + +class MissingMinSizeError { + /** + * @constructor + * @param {SizeMethod} sizeMethod + * @param {string|number} minSize + */ + constructor (sizeMethod, minSize) { + this.sizeMethod = sizeMethod; + this.minSize = minSize; + } + + /** + * @method + * @returns {boolean} + */ + check() { + return this.sizeMethod === SIZE_METHODS.RANGE && !this.minSize; + } + + /** + * @method + * @returns {string} + */ + render() { + return _("Minimum size is required"); + } +} + +class InvalidMaxSizeError { + /** + * @constructor + * @param {SizeMethod} sizeMethod + * @param {string|number} minSize + * @param {string|number} maxSize + */ + constructor (sizeMethod, minSize, maxSize) { + this.sizeMethod = sizeMethod; + this.minSize = minSize; + this.maxSize = maxSize; + } + + /** + * @method + * @returns {boolean} + */ + check() { + return this.sizeMethod === SIZE_METHODS.RANGE && + this.maxSize !== -1 && + this.maxSize <= this.minSize; + } + + /** + * @method + * @returns {string} + */ + render() { + return _("Maximum must be greater than minimum"); + } +} + +class ExistingVolumeError { + /** + * @constructor + * @param {string} mountPath + * @param {Volume[]} volumes + */ + constructor(mountPath, volumes) { + this.mountPath = mountPath; + this.volumes = volumes; + } + + /** + * @method + * @returns {Volume|undefined} + */ + findVolume() { + return this.volumes.find(t => t.mountPath === this.mountPath); + } + + /** + * @method + * @returns {boolean} + */ + check() { + return this.mountPath.length && this.findVolume() !== undefined; + } + + /** + * @method + * @param {(volume: Volume) => void} onClick + * @returns {React.ReactElement} + */ + render(onClick) { + const volume = this.findVolume(); + const path = this.mountPath === "/" ? "root" : this.mountPath; + + return ( +
+ {sprintf(_("There is already a file system for %s."), path)} + +
+ ); + } +} + +class ExistingTemplateError { + /** + * @constructor + * @param {string} mountPath + * @param {Volume[]} templates + */ + constructor(mountPath, templates) { + this.mountPath = mountPath; + this.templates = templates; + } + + /** + * @method + * @returns {Volume|undefined} + */ + findTemplate() { + return this.templates.find(t => t.mountPath === this.mountPath); + } + + /** + * @method + * @returns {boolean} + */ + check() { + return this.mountPath.length && this.findTemplate() !== undefined; + } + + /** + * @method + * @param {(template: Volume) => void} onClick + * @returns {React.ReactElement} + */ + render(onClick) { + const template = this.findTemplate(); + const path = this.mountPath === "/" ? "root" : this.mountPath; + + return ( +
+ {sprintf(_("There is a predefined file system for %s."), path)} + +
+ ); + } +} + +/** + * Error if the mount path is missing. + * @function + * + * @param {string} mountPath + * @returns {string|null} + */ +const missingMountPathError = (mountPath) => { + const error = new MissingMountPathError(mountPath); + return error.check() ? error.render() : null; +}; + +/** + * Error if the mount path is not valid. + * @function + * + * @param {string} mountPath + * @returns {string|null} + */ +const invalidMountPathError = (mountPath) => { + const error = new InvalidMountPathError(mountPath); + return error.check() ? error.render() : null; +}; + +/** + * Error if the size is missing. + * @function + * + * @param {SizeMethod} sizeMethod + * @param {string|number} size + * @returns {string|null} + */ +const missingSizeError = (sizeMethod, size) => { + const error = new MissingSizeError(sizeMethod, size); + return error.check() ? error.render() : null; +}; + +/** + * Error if the min size is missing. + * @function + * + * @param {SizeMethod} sizeMethod + * @param {string|number} minSize + * @returns {string|null} + */ +const missingMinSizeError = (sizeMethod, minSize) => { + const error = new MissingMinSizeError(sizeMethod, minSize); + return error.check() ? error.render() : null; +}; + +/** + * Error if the max size is not valid. + * @function + * + * @param {SizeMethod} sizeMethod + * @param {string|number} minSize + * @param {string|number} maxSize + * @returns {string|null} + */ +const invalidMaxSizeError = (sizeMethod, minSize, maxSize) => { + const error = new InvalidMaxSizeError(sizeMethod, minSize, maxSize); + return error.check() ? error.render() : null; +}; + +/** + * Error if the given mount path exists in the list of volumes. + * @function + * + * @param {string} mountPath + * @param {Volume[]} volumes + * @param {(volume: Volume) => void} onClick + * @returns {React.ReactElement|null} + */ +const existingVolumeError = (mountPath, volumes, onClick) => { + const error = new ExistingVolumeError(mountPath, volumes); + return error.check() ? error.render(onClick) : null; +}; + +/** + * Error if the given mount path exists in the list of templates. + * @function + * + * @param {string} mountPath + * @param {Volume[]} templates + * @param {(template: Volume) => void} onClick + * @returns {React.ReactElement|null} + */ +const existingTemplateError = (mountPath, templates, onClick) => { + const error = new ExistingTemplateError(mountPath, templates); + return error.check() ? error.render(onClick) : null; +}; + +/** + * Checks whether there is any error. + * @function + * + * @param {VolumeFormErrors} errors + * @returns {boolean} + */ +const anyError = (errors) => { + return compact(Object.values(errors)).length > 0; +}; + +/** + * Remove leftover trailing slash. + * @function + * + * @param {string} mountPath + * @returns {string} + */ +const sanitizeMountPath = (mountPath) => { + if (mountPath === "/") return mountPath; + + return mountPath.replace(/\/$/, ""); +}; + +/** + * Creates a new storage volume object based on given params. + * @function + * + * @param {Volume} volume + * @param {VolumeFormData} formData + * @returns {Volume} + */ +const createUpdatedVolume = (volume, formData) => { + let sizeAttrs = {}; + const size = parseToBytes(`${formData.size} ${formData.sizeUnit}`); + const minSize = parseToBytes(`${formData.minSize} ${formData.minSizeUnit}`); + const maxSize = parseToBytes(`${formData.maxSize} ${formData.maxSizeUnit}`); + + switch (formData.sizeMethod) { + case SIZE_METHODS.AUTO: + sizeAttrs = { minSize: undefined, maxSize: undefined, autoSize: true }; + break; + case SIZE_METHODS.MANUAL: + sizeAttrs = { minSize: size, maxSize: size, autoSize: false }; + break; + case SIZE_METHODS.RANGE: + sizeAttrs = { minSize, maxSize: formData.maxSize ? maxSize : undefined, autoSize: false }; + break; + } + + const { fsType, snapshots } = formData; + const mountPath = sanitizeMountPath(formData.mountPath); + + return { ...volume, mountPath, ...sizeAttrs, fsType, snapshots }; +}; + +/** + * Form-related helper for guessing the size method for given volume + * @function + * + * @param {Volume} volume - a storage volume + * @return {SizeMethod} corresponding size method + */ +const sizeMethodFor = (volume) => { + const { autoSize, minSize, maxSize } = volume; + + if (autoSize) { + return SIZE_METHODS.AUTO; + } else if (minSize !== maxSize) { + return SIZE_METHODS.RANGE; + } else { + return SIZE_METHODS.MANUAL; + } +}; + +/** + * Form-related helper for preparing data based on given volume + * @function + * + * @param {Volume} volume - a storage volume object + * @return {VolumeFormData} an object ready to be used as a "form state" + */ +const prepareFormData = (volume) => { + const { size: minSize = "", unit: minSizeUnit = DEFAULT_SIZE_UNIT } = splitSize(volume.minSize); + const { size: maxSize = "", unit: maxSizeUnit = minSizeUnit || DEFAULT_SIZE_UNIT } = splitSize(volume.maxSize); + + return { + size: minSize, + sizeUnit: minSizeUnit, + minSize, + minSizeUnit, + maxSize, + maxSizeUnit, + sizeMethod: sizeMethodFor(volume), + mountPath: volume.mountPath, + fsType: volume.fsType, + snapshots: volume.snapshots + }; +}; + +/** + * Possible errors from the form data. + * @function + * + * @returns {VolumeFormErrors} + */ +const prepareErrors = () => { + return { + missingMountPath: null, + invalidMountPath: null, + existingVolume: null, + existingTemplate: null, + missingSize: null, + missingMinSize: null, + invalidMaxSize: null + }; +}; + +/** + * Initializer function for the React#useReducer used in the {@link VolumesForm} + * @function + * + * @param {Volume} volume - a storage volume object + * @returns {VolumeFormState} + */ +const createInitialState = (volume) => { + const formData = prepareFormData(volume); + const errors = prepareErrors(); + + return { volume, formData, errors }; +}; + +/** + * The VolumeForm reducer. + * @function + * + * @param {VolumeFormState} state + * @param {object} action + */ +const reducer = (state, action) => { + const { type, payload } = action; + + switch (type) { + case "CHANGE_VOLUME": { + return createInitialState(payload.volume); + } + + case "UPDATE_DATA": { + return { + ...state, + formData: { + ...state.formData, + ...payload + } + }; + } + + case "SET_ERRORS": { + const errors = { ...state.errors, ...payload }; + return { ...state, errors }; + } + + default: { + return state; + } + } +}; + +/** + * Renders a dialog that allows the user to add or edit a file system. + * @component + * + * @typedef {object} VolumeDialogProps + * @property {Volume} volume + * @property {Volume[]} volumes + * @property {Volume[]} templates + * @property {boolean} [isOpen=false] + * @property {() => void} onCancel + * @property {(volume: Volume) => void} onAccept + * + * @param {VolumeDialogProps} props + */ +export default function VolumeDialog({ + volume: currentVolume, + volumes, + templates, + isOpen, + onCancel, + onAccept +}) { + /** @type {[VolumeFormState, (action: object) => void]} */ + const [state, dispatch] = useReducer(reducer, currentVolume, createInitialState); + + /** @type {Function} */ + const delayed = useDebounce(f => f(), 1000); + + /** @type {(volume: Volume) => void} */ + const changeVolume = (volume) => { + dispatch({ type: "CHANGE_VOLUME", payload: { volume } }); + }; + + /** @type {(data: object) => void} */ + const updateData = (data) => dispatch({ type: "UPDATE_DATA", payload: data }); + + /** @type {(errors: object) => void} */ + const updateErrors = (errors) => dispatch({ type: "SET_ERRORS", payload: errors }); + + /** @type {() => string|React.ReactElement} */ + const mountPathError = () => { + const { missingMountPath, invalidMountPath, existingVolume, existingTemplate } = state.errors; + return missingMountPath || invalidMountPath || existingVolume || existingTemplate; + }; + + /** @type {() => object} */ + const sizeErrors = () => { + return { + size: state.errors.missingSize, + minSize: state.errors.missingMinSize, + maxSize: state.errors.invalidMaxSize + }; + }; + + /** @type {() => boolean} */ + const disableWidgets = () => { + const { existingVolume, existingTemplate } = state.errors; + return existingVolume !== null || existingTemplate !== null; + }; + + /** @type {() => boolean} */ + const isMountPathEditable = () => { + const isNewVolume = !volumes.includes(state.volume); + const isPredefined = state.volume.outline.productDefined; + return isNewVolume && !isPredefined; + }; + + /** @type {(mountPath: string) => void} */ + const changeMountPath = (mountPath) => { + // Reset current errors. + const errors = { + missingMountPath: null, + invalidMountPath: null, + existingVolume: null, + existingTemplate: null + }; + updateErrors(errors); + + delayed(() => { + // Reevaluate in a delayed way. + const errors = { + existingVolume: existingVolumeError(mountPath, volumes, changeVolume), + existingTemplate: existingTemplateError(mountPath, templates, changeVolume) + }; + updateErrors(errors); + }); + + updateData({ mountPath }); + }; + + /** @type {(data: object) => void} */ + const changeSizeOptions = (data) => { + // Reset errors. + const errors = { + missingSize: null, + missingMinSize: null, + invalidMaxSize: null + }; + updateErrors(errors); + updateData(data); + }; + + /** @type {(e: import("react").FormEvent) => void} */ + const submitForm = (e) => { + e.preventDefault(); + const { volume: originalVolume, formData } = state; + const volume = createUpdatedVolume(originalVolume, formData); + + const checkMountPath = isMountPathEditable(); + + const errors = { + missingMountPath: checkMountPath ? missingMountPathError(volume.mountPath) : null, + invalidMountPath: checkMountPath ? invalidMountPathError(volume.mountPath) : null, + existingVolume: checkMountPath ? existingVolumeError(volume.mountPath, volumes, changeVolume) : null, + existingTemplate: checkMountPath ? existingTemplateError(volume.mountPath, templates, changeVolume) : null, + missingSize: missingSizeError(formData.sizeMethod, volume.minSize), + missingMinSize: missingMinSizeError(formData.sizeMethod, volume.minSize), + invalidMaxSize: invalidMaxSizeError(formData.sizeMethod, volume.minSize, volume.maxSize) + }; + + anyError(errors) ? updateErrors(errors) : onAccept(volume); + }; + + const title = renderTitle(state.volume, volumes); + const { fsType, mountPath } = state.formData; + const isDisabled = disableWidgets(); + + return ( + /** @fixme blockSize medium is too big and small is too small. */ + +
+ +

{mountPath}

+ + } + else={ + + } + /> + + + + + + {_("Accept")} + + + +
+ ); +} diff --git a/web/src/components/storage/VolumeForm.test.jsx b/web/src/components/storage/VolumeDialog.test.jsx similarity index 99% rename from web/src/components/storage/VolumeForm.test.jsx rename to web/src/components/storage/VolumeDialog.test.jsx index a3ca147f48..5179b29e27 100644 --- a/web/src/components/storage/VolumeForm.test.jsx +++ b/web/src/components/storage/VolumeDialog.test.jsx @@ -19,6 +19,8 @@ * find current contact information at www.suse.com. */ +/** @fixme Adapt to VolumeDialog */ + import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; diff --git a/web/src/components/storage/VolumeForm.jsx b/web/src/components/storage/VolumeFields.jsx similarity index 54% rename from web/src/components/storage/VolumeForm.jsx rename to web/src/components/storage/VolumeFields.jsx index dbf4382a0c..36f54c1f49 100644 --- a/web/src/components/storage/VolumeForm.jsx +++ b/web/src/components/storage/VolumeFields.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2024] SUSE LLC * * All Rights Reserved. * @@ -21,29 +21,53 @@ // @ts-check -import React, { useReducer, useState } from "react"; +import React, { useState } from "react"; import { - InputGroup, InputGroupItem, Form, FormGroup, FormSelect, FormSelectOption, MenuToggle, - Popover, Radio, Select, SelectOption, SelectList + InputGroup, InputGroupItem, FormGroup, FormSelect, FormSelectOption, MenuToggle, Popover, Radio, + Select, SelectOption, SelectList, TextInput } from "@patternfly/react-core"; import { sprintf } from "sprintf-js"; import { _, N_ } from "~/i18n"; import { FormValidationError, If, NumericTextInput } from '~/components/core'; -import { DEFAULT_SIZE_UNIT, SIZE_METHODS, SIZE_UNITS, parseToBytes, splitSize } from '~/components/storage/utils'; import { Icon } from "~/components/layout"; +import { SIZE_METHODS, SIZE_UNITS } from '~/components/storage/utils'; /** * @typedef {import ("~/client/storage").Volume} Volume */ /** - * Callback function for notifying a form input change + * Field for the mount path of a volume. + * @component + * + * @typedef {object} MountPathFieldProps + * @property {string} value + * @property {(mountPath: string) => void} onChange + * @property {string|React.ReactElement} [error] * - * @callback onChangeFn - * @param {object} an object with the changed input and its new value - * @return {void} + * @param {MountPathFieldProps} props */ +const MountPathField = ({ value, onChange, error }) => { + /** @type {(_: any, mountPath: string) => void} */ + const changeMountPath = (_, mountPath) => onChange(mountPath); + + return ( + <> + + + + + + ); +}; /** * Form control for selecting a size unit @@ -65,64 +89,6 @@ const SizeUnitFormSelect = ({ units, ...formSelectProps }) => { ); }; -/** - * Form control for selecting a mount point - * @component - * - * Based on {@link PF/FormSelect https://www.patternfly.org/components/forms/form-select} - * - * @param {object} props - * @param {string} props.value - mountPath of current selected volume - * @param {Array} props.volumes - a collection of storage volumes - * @param {onChangeFn} props.onChange - callback for notifying input changes - * @param {import("@patternfly/react-core").SelectProps} [props.selectProps] -*/ - -const MountPointFormSelect = ({ value, volumes, onChange, ...selectProps }) => { - const [isOpen, setIsOpen] = useState(false); - - const onSelect = (_, mountPath) => { - setIsOpen(false); - onChange(mountPath); - }; - - const onToggleClick = () => { - setIsOpen(!isOpen); - }; - - const toggle = toggleRef => { - return ( - - {value || _("Select a value")} - - ); - }; - return ( - - ); -}; - /** * Possible file system type options for a volume. * @function @@ -157,9 +123,10 @@ const FsSelectOption = ({ fsOption }) => { * @param {string} props.id - Widget id. * @param {string} props.value - Currently selected file system. * @param {Volume} props.volume - The selected storage volume. - * @param {onChangeFn} props.onChange - Callback for notifying input changes. + * @param {boolean} props.isDisabled + * @param {(data: object) => void} props.onChange - Callback for notifying input changes. */ -const FsSelect = ({ id, value, volume, onChange }) => { +const FsSelect = ({ id, value, volume, isDisabled, onChange }) => { const [isOpen, setIsOpen] = useState(false); const options = fsOptions(volume); @@ -182,6 +149,7 @@ const FsSelect = ({ id, value, volume, onChange }) => { onClick={onToggleClick} isExpanded={isOpen} className="full-width" + isDisabled={isDisabled} > {selected} @@ -213,12 +181,15 @@ const FsSelect = ({ id, value, volume, onChange }) => { * text with the unique option. * @component * - * @param {object} props - * @param {string} props.value - Currently selected file system. - * @param {Volume} props.volume - The selected storage volume. - * @param {onChangeFn} props.onChange - Callback for notifying input changes. + * @typedef {object} FsFieldProps + * @property {string} value - Currently selected file system. + * @property {Volume} volume - The selected storage volume. + * @property {boolean} isDisabled + * @property {(data: object) => void} onChange - Callback for notifying input changes. + * + * @param {FsFieldProps} props */ -const FsField = ({ value, volume, onChange }) => { +const FsField = ({ value, volume, isDisabled, onChange }) => { const isSingleFs = () => { // check for btrfs with snapshots if (volume.fsType === "Btrfs" && volume.snapshots) { @@ -260,7 +231,13 @@ const FsField = ({ value, volume, onChange }) => { } else={ } fieldId="fsType"> - + } /> @@ -312,9 +289,10 @@ const SizeAuto = ({ volume }) => { * @param {object} props * @param {object} props.errors - the form errors * @param {object} props.formData - the form data - * @param {onChangeFn} props.onChange - callback for notifying input changes + * @param {boolean} props.isDisabled + * @param {(v: object) => void} props.onChange - callback for notifying input changes */ -const SizeManual = ({ errors, formData, onChange }) => { +const SizeManual = ({ errors, formData, isDisabled, onChange }) => { return (

@@ -340,6 +318,7 @@ const SizeManual = ({ errors, formData, onChange }) => { value={formData.size} onChange={(size) => onChange({ size })} validated={errors.size && 'error'} + isDisabled={isDisabled} /> @@ -351,6 +330,7 @@ const SizeManual = ({ errors, formData, onChange }) => { units={Object.values(SIZE_UNITS)} value={formData.sizeUnit } onChange={(_, sizeUnit) => onChange({ sizeUnit })} + isDisabled={isDisabled} /> @@ -367,9 +347,10 @@ const SizeManual = ({ errors, formData, onChange }) => { * @param {object} props * @param {object} props.errors - the form errors * @param {object} props.formData - the form data - * @param {onChangeFn} props.onChange - callback for notifying input changes + * @param {boolean} props.isDisabled + * @param {(v: object) => void} props.onChange - callback for notifying input changes */ -const SizeRange = ({ errors, formData, onChange }) => { +const SizeRange = ({ errors, formData, isDisabled, onChange }) => { return (

@@ -395,6 +376,7 @@ and maximum. If no maximum is given then the file system will be as big as possi value={formData.minSize} onChange={(minSize) => onChange({ minSize })} validated={errors.minSize && 'error'} + isDisabled={isDisabled} /> @@ -405,6 +387,7 @@ and maximum. If no maximum is given then the file system will be as big as possi units={Object.values(SIZE_UNITS)} value={formData.minSizeUnit } onChange={(_, minSizeUnit) => onChange({ minSizeUnit })} + isDisabled={isDisabled} /> @@ -427,6 +410,7 @@ and maximum. If no maximum is given then the file system will be as big as possi aria-label={_("Maximum desired size")} value={formData.maxSize} onChange={(maxSize) => onChange({ maxSize })} + isDisabled={isDisabled} /> @@ -437,6 +421,7 @@ and maximum. If no maximum is given then the file system will be as big as possi units={Object.values(SIZE_UNITS)} value={formData.maxSizeUnit || formData.minSizeUnit } onChange={(_, maxSizeUnit) => onChange({ maxSizeUnit })} + isDisabled={isDisabled} /> @@ -461,15 +446,18 @@ const SIZE_OPTION_LABELS = Object.freeze({ * Widget for rendering the volume size options * @component * - * @param {object} props - * @param {object} props.errors - the form errors - * @param {object} props.formData - the form data - * @param {Volume} props.volume - the selected storage volume - * @param {onChangeFn} props.onChange - callback for notifying input changes + * @typedef {object} SizeOptionsFieldProps + * @property {object} errors - the form errors + * @property {object} formData - the form data + * @property {Volume} volume - the selected storage volume + * @property {boolean} isDisabled + * @property {(v: object) => void} onChange - callback for notifying input changes + * + * @param {SizeOptionsFieldProps} props */ -const SizeOptions = ({ errors, formData, volume, onChange }) => { +const SizeOptionsField = ({ errors, formData, volume, isDisabled, onChange }) => { const { sizeMethod } = formData; - const sizeWidgetProps = { errors, formData, volume, onChange }; + const sizeWidgetProps = { errors, formData, volume, isDisabled, onChange }; /** @type {string[]} */ const sizeOptions = [SIZE_METHODS.MANUAL, SIZE_METHODS.RANGE]; @@ -477,255 +465,37 @@ const SizeOptions = ({ errors, formData, volume, onChange }) => { if (volume.outline.supportAutoSize) sizeOptions.push(SIZE_METHODS.AUTO); return ( -

-
- { sizeOptions.map((value) => { - const isSelected = sizeMethod === value; - - return ( - onChange({ sizeMethod: value })} - /> - ); - })} -
- -
- } /> - } /> - } /> + +
+
+ { sizeOptions.map((value) => { + const isSelected = sizeMethod === value; + + return ( + onChange({ sizeMethod: value })} + isDisabled={isDisabled} + /> + ); + })} +
+ +
+ } /> + } /> + } /> +
-
+ ); }; -/** - * Creates a new storage volume object based on given params - * - * @param {Volume} volume - a storage volume - * @param {object} formData - data used to calculate the volume updates - * @returns {object} storage volume object - */ -const createUpdatedVolume = (volume, formData) => { - let sizeAttrs = {}; - const size = parseToBytes(`${formData.size} ${formData.sizeUnit}`); - const minSize = parseToBytes(`${formData.minSize} ${formData.minSizeUnit}`); - const maxSize = parseToBytes(`${formData.maxSize} ${formData.maxSizeUnit}`); - - switch (formData.sizeMethod) { - case SIZE_METHODS.AUTO: - sizeAttrs = { minSize: undefined, maxSize: undefined, autoSize: true }; - break; - case SIZE_METHODS.MANUAL: - sizeAttrs = { minSize: size, maxSize: size, autoSize: false }; - break; - case SIZE_METHODS.RANGE: - sizeAttrs = { minSize, maxSize: formData.maxSize ? maxSize : undefined, autoSize: false }; - break; - } - - const { fsType, snapshots } = formData; - - return { ...volume, ...sizeAttrs, fsType, snapshots }; -}; - -/** - * Form-related helper for guessing the size method for given volume - * - * @param {Volume} volume - a storage volume - * @return {string} corresponding size method - */ -const sizeMethodFor = (volume) => { - const { autoSize, minSize, maxSize } = volume; - - if (autoSize) { - return SIZE_METHODS.AUTO; - } else if (minSize !== maxSize) { - return SIZE_METHODS.RANGE; - } else { - return SIZE_METHODS.MANUAL; - } -}; - -/** - * Form-related helper for preparing data based on given volume - * - * @param {Volume} volume - a storage volume object - * @return {object} an object ready to be used as a "form state" - */ -const prepareFormData = (volume) => { - const { size: minSize = "", unit: minSizeUnit = DEFAULT_SIZE_UNIT } = splitSize(volume.minSize); - const { size: maxSize = "", unit: maxSizeUnit = minSizeUnit || DEFAULT_SIZE_UNIT } = splitSize(volume.maxSize); - - return { - size: minSize, - sizeUnit: minSizeUnit, - minSize, - minSizeUnit, - maxSize, - maxSizeUnit, - sizeMethod: sizeMethodFor(volume), - mountPoint: volume.mountPath, - fsType: volume.fsType, - snapshots: volume.snapshots - }; -}; - -/** - * Initializer function for the React#useReducer used in the {@link VolumesForm} - * - * @param {Volume} volume - a storage volume object - * @returns {object} a ready to use initial state - */ -const createInitialState = (volume) => { - return { - volume, - formData: prepareFormData(volume), - errors: {} - }; -}; - -/** - * The VolumeForm reducer - */ -const reducer = (state, action) => { - const { type, payload } = action; - - switch (type) { - case "CHANGE_VOLUME": { - return createInitialState(payload.volume); - } - - case "UPDATE_DATA": { - return { - ...state, - formData: { - ...state.formData, - ...payload - } - }; - } - - case "SET_ERRORS": { - return { ...state, errors: payload }; - } - - default: { - return state; - } - } -}; - -/** - * Form used for adding a new file system from a list of templates - * @component - * - * @note VolumeForm does not provide a submit button. It is the consumer's - * responsibility to provide both: the button for triggering the submission by - * using the form id and the callback function used to perform the submission - * once the form has been validated. - * - * @param {object} props - * @param {string} props.id - Form ID - * @param {Volume} [props.volume] - Volume if editing - * @param {Volume[]} props.templates - * @param {onSubmitFn} props.onSubmit - Function to use for submitting a new volume - * - * @callback onSubmitFn - * @param {Volume} volume - a storage volume object - * @return {void} - */ -export default function VolumeForm({ id, volume: currentVolume, templates = [], onSubmit }) { - /** @type {[object, (action: object) => void]} */ - const [state, dispatch] = useReducer(reducer, currentVolume || templates[0], createInitialState); - - const changeVolume = (mountPath) => { - const volume = templates.find(t => t.mountPath === mountPath); - dispatch({ type: "CHANGE_VOLUME", payload: { volume } }); - }; - - const updateData = (data) => dispatch({ type: "UPDATE_DATA", payload: data }); - - const validateVolumeSize = (sizeMethod, volume) => { - const errors = {}; - const { minSize, maxSize } = volume; - - switch (sizeMethod) { - case SIZE_METHODS.AUTO: - break; - case SIZE_METHODS.MANUAL: - if (!minSize) { - errors.size = _("A size value is required"); - } - break; - case SIZE_METHODS.RANGE: - if (!minSize) { - errors.minSize = _("Minimum size is required"); - } - - if (maxSize !== -1 && maxSize <= minSize) { - errors.maxSize = _("Maximum must be greater than minimum"); - } - break; - } - - return errors; - }; - - const submitForm = (e) => { - e.preventDefault(); - const { volume: originalVolume, formData } = state; - const volume = createUpdatedVolume(originalVolume, formData); - const errors = validateVolumeSize(formData.sizeMethod, volume); - - dispatch({ type: "SET_ERRORS", payload: errors }); - - if (!Object.keys(errors).length) onSubmit(volume); - }; - - const { fsType } = state.formData; - - const ShowMountPointSelector = () => ( - - ); - - const ShowMountPoint = () =>

{state.formData.mountPoint}

; - - return ( -
- - - - } - else={ - - - - } - /> - - - - - - ); -} +export { FsField, MountPathField, SizeOptionsField }; diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index 971542f903..c3a8d206cc 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -32,7 +32,6 @@ export { default as ZFCPPage } from "./ZFCPPage"; export { default as ZFCPDiskForm } from "./ZFCPDiskForm"; export { default as ISCSIPage } from "./ISCSIPage"; export { DeviceList, DeviceSelector, DeviceContentInfo, DeviceExtendedInfo, FilesystemLabel } from "./device-utils"; -export { default as VolumeForm } from "./VolumeForm"; export { default as BootSelectionDialog } from "./BootSelectionDialog"; export { default as DeviceSelectionDialog } from "./DeviceSelectionDialog"; export { default as DeviceSelectorTable } from "./DeviceSelectorTable"; From 2291c32e63475e65b0305194a80ec3ef9dce0424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 22 Apr 2024 16:40:26 +0100 Subject: [PATCH 06/18] web: Allow adding arbitrary volumes - Missing unit tests. --- .../components/storage/PartitionsField.jsx | 263 ++++++++++++------ .../storage/ProposalSettingsSection.jsx | 14 +- 2 files changed, 184 insertions(+), 93 deletions(-) diff --git a/web/src/components/storage/PartitionsField.jsx b/web/src/components/storage/PartitionsField.jsx index 6c8aeafc4d..dc971ff49c 100644 --- a/web/src/components/storage/PartitionsField.jsx +++ b/web/src/components/storage/PartitionsField.jsx @@ -22,15 +22,19 @@ // @ts-check import React, { useState } from "react"; -import { Button, List, ListItem, Skeleton } from '@patternfly/react-core'; +import { + Button, Dropdown, DropdownList, DropdownItem, List, ListItem, MenuToggle, Skeleton +} from '@patternfly/react-core'; import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { If, ExpandableField, Popup, RowActions, Tip } from '~/components/core'; -import { VolumeForm } from '~/components/storage'; +import { If, ExpandableField, RowActions, Tip } from '~/components/core'; +import VolumeDialog from '~/components/storage/VolumeDialog'; import VolumeLocationDialog from '~/components/storage/VolumeLocationDialog'; -import { deviceSize, hasSnapshots, isTransactionalRoot } from '~/components/storage/utils'; +import { + deviceSize, hasSnapshots, isTransactionalRoot, isTransactionalSystem +} from '~/components/storage/utils'; import SnapshotsField from "~/components/storage/SnapshotsField"; import BootConfigField from "~/components/storage/BootConfigField"; import { noop } from "~/utils"; @@ -38,6 +42,8 @@ import { noop } from "~/utils"; /** * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import("~/components/storage/SnapshotsField").SnapshotsConfig} SnapshotsConfig + * * @typedef {import ("~/client/storage").Volume} Volume */ @@ -202,57 +208,6 @@ const AutoCalculatedHint = ({ volume }) => { ); }; -/** - * Button with general actions for the file systems - * @component - * - * @param {object} props - * @param {object[]} props.templates - Volume templates - * @param {onAddFn} props.onAdd - Function to use for adding a new volume - * @param {onResetFn} props.onReset - Function to use for resetting to the default subvolumes - * - * @callback onAddFn - * @param {object} volume - * @return {void} - * - * @callback onResetFn - * @return {void} - */ -const GeneralActions = ({ templates, onAdd, onReset }) => { - const [isFormOpen, setIsFormOpen] = useState(false); - - const openForm = () => setIsFormOpen(true); - - const closeForm = () => setIsFormOpen(false); - - const acceptForm = (volume) => { - closeForm(); - onAdd(volume); - }; - - return ( -
- - - - - - {_("Accept")} - - - -
- ); -}; - /** * @component * @@ -290,6 +245,8 @@ const BootLabel = ({ bootDevice, configureBoot }) => { * @param {object} props * @param {object} [props.columns] - Column specs * @param {Volume} [props.volume] - Volume to show + * @param {Volume[]} [props.volumes] - List of current volumes + * @param {Volume[]} [props.templates] - List of available templates * @param {StorageDevice[]} [props.devices=[]] - Devices available for installation * @param {ProposalTarget} [props.target] - Installation target * @param {StorageDevice} [props.targetDevice] - Device selected for installation, if target is a disk @@ -300,6 +257,8 @@ const BootLabel = ({ bootDevice, configureBoot }) => { const VolumeRow = ({ columns, volume, + volumes, + templates, devices, target, targetDevice, @@ -440,20 +399,19 @@ const VolumeRow = ({ /> - - - - - {_("Accept")} - - - - + + } + /> void} props.onVolumesChange - Function to submit changes in volumes */ -const VolumesTable = ({ volumes, devices, target, targetDevice, isLoading, onVolumesChange }) => { +const VolumesTable = ({ + volumes, + templates, + devices, + target, + targetDevice, + isLoading, + onVolumesChange +}) => { const columns = { mountPath: _("Mount point"), details: _("Details"), @@ -494,6 +461,7 @@ const VolumesTable = ({ volumes, devices, target, targetDevice, isLoading, onVol actions: _("Actions") }; + /** @type {(volume: Volume) => void} */ const editVolume = (volume) => { const index = volumes.findIndex(v => v.mountPath === volume.mountPath); const newVolumes = [...volumes]; @@ -501,11 +469,13 @@ const VolumesTable = ({ volumes, devices, target, targetDevice, isLoading, onVol onVolumesChange(newVolumes); }; + /** @type {(volume: Volume) => void} */ const deleteVolume = (volume) => { const newVolumes = volumes.filter(v => v.mountPath !== volume.mountPath); onVolumesChange(newVolumes); }; + /** @type {() => React.ReactElement[]|React.ReactElement} */ const renderVolumes = () => { if (volumes.length === 0 && isLoading) return ; @@ -515,6 +485,8 @@ const VolumesTable = ({ volumes, devices, target, targetDevice, isLoading, onVol key={index} columns={columns} volume={volume} + volumes={volumes} + templates={templates} devices={devices} target={target} targetDevice={targetDevice} @@ -573,6 +545,68 @@ const Basic = ({ volumes, configureBoot, bootDevice, target, isLoading }) => { ); }; +/** + * Button for adding a new volume. It renders either a menu or a button depending on the number + * of options. + * @component + * + * @param {object} props + * @param {string[]} props.options - Possible mount points to add. An empty string represent an + * arbitrary mount point. + * @param {(option: string) => void} props.onClick + */ +const AddVolumeButton = ({ options, onClick }) => { + const [isOpen, setIsOpen] = React.useState(false); + + /** @type {() => void} */ + const onToggleClick = () => setIsOpen(!isOpen); + + /** @type {(_: any, value: string) => void} */ + const onSelect = (_, value) => { + setIsOpen(false); + onClick(value); + }; + + // Shows a button if the only option is to add an arbitrary volume. + if (options.length === 1 && options[0] === "") { + return ( + + ); + } + + const isDisabled = !options.length; + + return ( + ( + + {_("Add file system")} + + )} + shouldFocusToggleOnSelect + > + + {options.map((option, index) => { + return ( + + {option === "" ? _("Other") : option} + + ); + })} + + + ); +}; + /** * Content to show when the field is expanded. * @component @@ -603,15 +637,69 @@ const Advanced = ({ onBootChange, isLoading }) => { - const rootVolume = (volumes || []).find((i) => i.mountPath === "/"); + const [isVolumeDialogOpen, setIsVolumeDialogOpen] = useState(false); + /** @type {[Volume|undefined, (volume: Volume) => void]} */ + const [template, setTemplate] = useState(); + + const openVolumeDialog = () => setIsVolumeDialogOpen(true); + + const closeVolumeDialog = () => setIsVolumeDialogOpen(false); + + /** @type {(volume: Volume) => void} */ + const onAcceptVolumeDialog = (volume) => { + closeVolumeDialog(); - const addVolume = (volume) => onVolumesChange([...volumes, volume]); + const index = volumes.findIndex(v => v.mountPath === volume.mountPath); + + if (index !== -1) { + const newVolumes = [...volumes]; + newVolumes[index] = volume; + onVolumesChange(newVolumes); + } else { + onVolumesChange([...volumes, volume]); + } + }; const resetVolumes = () => onVolumesChange([]); - const changeBtrfsSnapshots = ({ active }) => { - // const rootVolume = volumes.find((i) => i.mountPath === "/"); + /** @type {(mountPath: string) => void} */ + const addVolume = (mountPath) => { + const template = templates.find(t => t.mountPath === mountPath); + setTemplate(template); + openVolumeDialog(); + }; + + /** + * Possible mount paths to add. + * @type {() => string[]} + */ + const mountPathOptions = () => { + const mountPaths = volumes.map(v => v.mountPath); + const isTransactional = isTransactionalSystem(templates); + + return templates + .map(t => t.mountPath) + .filter(p => !mountPaths.includes(p)) + .filter(p => !isTransactional || p.length); + }; + + /** + * Whether to show the button for adding a volume. + * @type {() => boolean} + */ + const showAddVolume = () => { + const hasOptionalVolumes = () => { + return templates.find(t => t.mountPath.length && !t.outline.required) !== undefined; + }; + + return !isTransactionalSystem(templates) || hasOptionalVolumes(); + }; + + /** @type {Volume} */ + const rootVolume = volumes.find(v => v.mountPath === "/"); + /** @type {(config: SnapshotsConfig) => void} */ + const changeBtrfsSnapshots = ({ active }) => { if (active) { rootVolume.fsType = "Btrfs"; rootVolume.snapshots = true; @@ -630,16 +718,32 @@ const Advanced = ({ /> - + } + /> + +
+ + } />
p.id === settings.spacePolicy); - /** - * Templates for already existing mount points are filtered out. - * - * @returns {Volume[]} - */ - const usefulTemplates = () => { - const mountPaths = volumes.map(v => v.mountPath); - return volumeTemplates.filter(t => ( - t.mountPath.length > 0 && !mountPaths.includes(t.mountPath) - )); - }; - return ( <>
@@ -144,7 +132,7 @@ export default function ProposalSettingsSection({ /> Date: Mon, 22 Apr 2024 16:41:08 +0100 Subject: [PATCH 07/18] web: Fix some types --- web/src/components/core/FormValidationError.jsx | 4 +++- web/src/components/storage/SnapshotsField.jsx | 5 ++++- web/src/components/storage/utils.js | 13 +++++-------- web/src/utils.js | 10 +++++----- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/web/src/components/core/FormValidationError.jsx b/web/src/components/core/FormValidationError.jsx index bb81dea922..d3924de1df 100644 --- a/web/src/components/core/FormValidationError.jsx +++ b/web/src/components/core/FormValidationError.jsx @@ -19,6 +19,8 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; import { FormHelperText, HelperText, HelperTextItem } from "@patternfly/react-core"; @@ -26,7 +28,7 @@ import { FormHelperText, HelperText, HelperTextItem } from "@patternfly/react-co * Helper component for displaying error messages in a PF/FormGroup * * @param {object} props - component props - * @param {string} [props.message] - text to be shown as error + * @param {string|React.ReactElement} [props.message] - text to be shown as error */ export default function FormValidationError({ message }) { if (!message) return; diff --git a/web/src/components/storage/SnapshotsField.jsx b/web/src/components/storage/SnapshotsField.jsx index d5ba48a25d..13b4810652 100644 --- a/web/src/components/storage/SnapshotsField.jsx +++ b/web/src/components/storage/SnapshotsField.jsx @@ -43,7 +43,10 @@ system after configuration changes or software upgrades."); * * @typedef {object} SnapshotsFieldProps * @property {Volume} rootVolume - * @property {(config: object) => void} onChange + * @property {(config: SnapshotsConfig) => void} onChange + * + * @typedef {object} SnapshotsConfig + * @property {boolean} active * * @param {SnapshotsFieldProps} props */ diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js index 1e842c211c..1d176c5bbb 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.js @@ -33,21 +33,18 @@ import { N_ } from "~/i18n"; */ /** - * @typedef {Object} SizeObject - * * @note undefined for either property means unknown - * + * @typedef {object} SizeObject * @property {number|undefined} size - The "amount" of size (10, 128, ...) * @property {string|undefined} unit - The size unit (MiB, GiB, ...) - */ - -/** - * @typedef SpacePolicy - * @type {object} + * + * @typedef {object} SpacePolicy * @property {string} id * @property {string} label * @property {string} description * @property {string[]} summaryLabels + * + * @typedef {"auto"|"fixed"|"range"} SizeMethod */ const SIZE_METHODS = Object.freeze({ diff --git a/web/src/utils.js b/web/src/utils.js index f9645ab238..7513fad6a1 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -97,8 +97,8 @@ function uniq(collection) { * * @todo Use https://github.com/JedWatson/classnames instead? * - * @param {...*} CSS classes to join - * @returns {String} CSS classes joined together after ignoring falsy values + * @param {...*} classes - CSS classes to join + * @returns {String} - CSS classes joined together after ignoring falsy values */ function classNames(...classes) { return classes.filter((item) => !!item).join(' '); @@ -209,9 +209,9 @@ const useLocalStorage = (storageKey, fallbackState) => { * * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} * - * @param {function} callback - Function to be called after some delay. + * @param {Function} callback - Function to be called after some delay. * @param {number} delay - Delay in milliseconds. - * @returns {function} + * @returns {Function} * * @example * @@ -255,7 +255,7 @@ const hex = (value) => { * * @todo This conversion will not be needed after adapting Section to directly work with issues. * - * @param {import("~/client/mixins").Issue} issues + * @param {import("~/client/mixins").Issue} issue * @returns {import("~/client/mixins").ValidationError} */ const toValidationError = (issue) => ({ message: issue.description }); From 6c561e993bb2b4fcb0989d9e7e9d233ff08ee313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 23 Apr 2024 17:42:53 +0100 Subject: [PATCH 08/18] web: Do not use HTML