diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 30ae533f8b..a28b65bab7 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Feb 22 14:05:56 UTC 2024 - David Diaz + +- Break storage settings in multiple sections to improve the UX + (gh#openSUSE/agama#1045). + ------------------------------------------------------------------- Wed Feb 21 17:40:01 UTC 2024 - David Diaz diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index a6d42c2e68..30e95e4aa4 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -18,24 +18,36 @@ margin-block-end: var(--spacer-medium); } - > h2 { + > header { display: grid; grid-area: header; grid-template-columns: subgrid; grid-column: bleed / content-end; - svg { - block-size: var(--section-icon-size); - inline-size: var(--section-icon-size); - grid-column: bleed / content; + h2 { + display: grid; + grid-template-columns: subgrid; + grid-column: bleed / content-end; + + svg { + block-size: var(--section-icon-size); + inline-size: var(--section-icon-size); + grid-column: bleed / content; + } + + :not(svg) { + grid-column: content + } } - :not(svg) { - grid-column: content + p { + grid-column: content; + color: var(--color-gray-dimmest); + margin-block-end: var(--spacer-smaller); } } - > :not(h2) { + > :not(header) { grid-area: content; grid-column: content; } diff --git a/web/src/assets/styles/layout.scss b/web/src/assets/styles/layout.scss index 6e879528f1..d8a7034ad5 100644 --- a/web/src/assets/styles/layout.scss +++ b/web/src/assets/styles/layout.scss @@ -20,7 +20,7 @@ padding: var(--spacer-small); } - header { + > header { @extend .bottom-shadow; grid-area: header; display: flex; diff --git a/web/src/assets/styles/patternfly-overrides.scss b/web/src/assets/styles/patternfly-overrides.scss index 90fa1bb699..9127b55a5b 100644 --- a/web/src/assets/styles/patternfly-overrides.scss +++ b/web/src/assets/styles/patternfly-overrides.scss @@ -127,7 +127,6 @@ table td > .pf-v5-c-empty-state { --stack-gutter: 0; --pf-v5-c-toolbar--PaddingTop: 0; --pf-v5-c-toolbar--PaddingBottom: 0; - border-block-end: 1px solid var(--color-gray-light); } .pf-v5-c-toolbar__content { diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss index fb96b3058f..b25bfcbad3 100644 --- a/web/src/assets/styles/variables.scss +++ b/web/src/assets/styles/variables.scss @@ -52,6 +52,7 @@ --color-gray-dark: #efefef; // Fog --color-gray-darker: #999; --color-gray-dimmed: #888; + --color-gray-dimmest: #666; --color-link: #0c322c; --color-link-hover: #30ba78; diff --git a/web/src/components/core/Section.jsx b/web/src/components/core/Section.jsx index 0a6b755740..c1954b7d4e 100644 --- a/web/src/components/core/Section.jsx +++ b/web/src/components/core/Section.jsx @@ -24,7 +24,7 @@ import React from "react"; import { Link } from "react-router-dom"; import { Icon } from '~/components/layout'; -import { ValidationErrors } from "~/components/core"; +import { If, ValidationErrors } from "~/components/core"; /** * Renders children into an HTML section @@ -48,6 +48,7 @@ import { ValidationErrors } from "~/components/core"; * @typedef { Object } SectionProps * @property {string} [icon] - Name of the section icon. Not rendered if title is not provided. * @property {string} [title] - The section title. If not given, aria-label must be provided. + * @property {string|React.ReactElement} [description] - A section description. Use only if really needed. * @property {string} [name] - The section name. Used to build the header id. * @property {string} [path] - Path where the section links to. * when user clicks on the title, used for opening a dialog. @@ -63,6 +64,7 @@ import { ValidationErrors } from "~/components/core"; export default function Section({ icon, title, + description, name, path, loading, @@ -84,9 +86,13 @@ export default function Section({ const iconName = loading ? "loading" : icon; const headerIcon = iconName ? : null; const headerText = !path?.trim() ? title : {title}; + const renderDescription = React.isValidElement(description) || description?.length > 0; return ( -

{headerIcon}{headerText}

+
+

{headerIcon}{headerText}

+ {description}

} /> +
); }; diff --git a/web/src/components/core/Section.test.jsx b/web/src/components/core/Section.test.jsx index d7d5db2a81..f132a637a6 100644 --- a/web/src/components/core/Section.test.jsx +++ b/web/src/components/core/Section.test.jsx @@ -36,7 +36,13 @@ describe("Section", () => { describe("when title is given", () => { it("renders the section header", () => { plainRender(
); - screen.getByRole("heading", { name: "Settings" }); + screen.getByRole("banner"); + }); + + it("renders given title as section heading", () => { + plainRender(
); + const header = screen.getByRole("banner"); + within(header).getByRole("heading", { name: "Settings" }); }); it("renders an icon if valid icon name is given", () => { @@ -58,15 +64,29 @@ describe("Section", () => { const icon = container.querySelector("svg"); expect(icon).toBeNull(); }); + + it("renders given description as part of the header", () => { + plainRender( +
+ ); + const header = screen.getByRole("banner"); + within(header).getByText(/Short explanation/); + }); }); describe("when title is not given", () => { it("does not render the section header", async () => { - plainRender(
); - const header = await screen.queryByRole("heading"); + plainRender(
); + const header = await screen.queryByRole("banner"); expect(header).not.toBeInTheDocument(); }); + it("does not render a section heading", async () => { + plainRender(
); + const heading = await screen.queryByRole("heading"); + expect(heading).not.toBeInTheDocument(); + }); + it("does not render the section icon", () => { const { container } = plainRender(
); const icon = container.querySelector("svg"); diff --git a/web/src/components/storage/ProposalActionsSection.jsx b/web/src/components/storage/ProposalActionsSection.jsx index e6a272b8e5..8d35563afd 100644 --- a/web/src/components/storage/ProposalActionsSection.jsx +++ b/web/src/components/storage/ProposalActionsSection.jsx @@ -25,7 +25,6 @@ import { ListItem, ExpandableSection, Skeleton, - Text } from "@patternfly/react-core"; import { sprintf } from "sprintf-js"; @@ -74,9 +73,6 @@ const ProposalActions = ({ actions = [] }) => { return ( <> - - {_("Actions to create the file systems and to ensure the system boots.")} - {subvolActions.length > 0 && ( +
} diff --git a/web/src/components/storage/ProposalActionsSection.test.jsx b/web/src/components/storage/ProposalActionsSection.test.jsx index 46d9bf4964..9864391d0d 100644 --- a/web/src/components/storage/ProposalActionsSection.test.jsx +++ b/web/src/components/storage/ProposalActionsSection.test.jsx @@ -65,8 +65,9 @@ it("renders skeleton while loading", () => { it("renders nothing when there is no actions", () => { plainRender(); - expect(screen.queryByText(/Actions to create/)).toBeNull(); + expect(screen.queryAllByText(/Delete/)).toEqual([]); expect(screen.queryAllByText(/Create/)).toEqual([]); + expect(screen.queryAllByText(/Show/)).toEqual([]); }); describe("when there are actions", () => { diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx new file mode 100644 index 0000000000..df37eaf304 --- /dev/null +++ b/web/src/components/storage/ProposalDeviceSection.jsx @@ -0,0 +1,441 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { + Button, + Form, + Skeleton, + Switch, + ToggleGroup, ToggleGroupItem, + Tooltip +} from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { Icon } from "~/components/layout"; +import { If, Section, Popup } from "~/components/core"; +import { DeviceList, DeviceSelector } from "~/components/storage"; +import { deviceLabel } from '~/components/storage/utils'; +import { noop } from "~/utils"; + +/** + * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").DevicesManager.StorageDevice} StorageDevice + */ + +/** + * Form for selecting the installation device. + * @component + * + * @param {object} props + * @param {string} props.id - Form ID. + * @param {StorageDevice} [props.current] - Currently selected device, if any. + * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection. + * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback. + * + * @callback onSubmitFn + * @param {string} device - Name of the selected device. + */ +const InstallationDeviceForm = ({ + id, + current, + devices = [], + onSubmit = noop +}) => { + const [device, setDevice] = useState(current || devices[0]); + + const changeSelected = (deviceId) => { + setDevice(devices.find(d => d.sid === deviceId)); + }; + + const submitForm = (e) => { + e.preventDefault(); + if (device !== undefined) onSubmit(device); + }; + + return ( +
+ + + ); +}; + +/** + * Allows to select the installation device. + * @component + * + * @callback onChangeFn + * @param {string} device - Name of the selected device. + * + * @param {object} props + * @param {string} [props.current] - Device name, if any. + * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection. + * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading. + * @param {onChangeFn} [props.onChange=noop] - On change callback. + */ +const InstallationDeviceField = ({ + current, + devices = [], + isLoading = false, + onChange = noop +}) => { + const [device, setDevice] = useState(devices.find(d => d.name === current)); + const [isFormOpen, setIsFormOpen] = useState(false); + + const openForm = () => setIsFormOpen(true); + + const closeForm = () => setIsFormOpen(false); + + const acceptForm = (selectedDevice) => { + closeForm(); + setDevice(selectedDevice); + onChange(selectedDevice); + }; + + /** + * Renders a button that allows changing selected device + * + * NOTE: if a device is already selected, its name and size will be used for + * the button text. Otherwise, a "No device selected" text will be shown. + * + * @param {object} props + * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any. + */ + const DeviceContent = ({ device }) => { + return ( + + ); + }; + + if (isLoading) { + return ; + } + + const description = _("Select the device for installing the system."); + + return ( + <> +
+ {_("Installation device")} + +
+ + + } + /> + + + {_("Accept")} + + + + + + ); +}; + +/** + * Form for configuring the system volume group. + * @component + * + * @param {object} props + * @param {string} props.id - Form ID. + * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. + * @param {StorageDevice[]} [props.devices=[]] - Available storage devices. + * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback. + * @param {onValidateFn} [props.onValidate=noop] - On validate callback. + * + * @callback onSubmitFn + * @param {string[]} devices - Name of the selected devices. + * + * @callback onValidateFn + * @param {boolean} valid + */ +const LVMSettingsForm = ({ + id, + settings, + devices = [], + onSubmit: onSubmitProp = noop, + onValidate = noop +}) => { + const [vgDevices, setVgDevices] = useState(settings.systemVGDevices); + const [isBootDeviceSelected, setIsBootDeviceSelected] = useState(settings.systemVGDevices.length === 0); + const [editedDevices, setEditedDevices] = useState(false); + + const selectBootDevice = () => { + setIsBootDeviceSelected(true); + onValidate(true); + }; + + const selectCustomDevices = () => { + setIsBootDeviceSelected(false); + const { bootDevice } = settings; + const customDevices = (vgDevices.length === 0 && !editedDevices) ? [bootDevice] : vgDevices; + setVgDevices(customDevices); + onValidate(customDevices.length > 0); + }; + + const onChangeDevices = (selection) => { + const selectedDevices = devices.filter(d => selection.includes(d.sid)).map(d => d.name); + setVgDevices(selectedDevices); + setEditedDevices(true); + onValidate(devices.length > 0); + }; + + const onSubmit = (e) => { + e.preventDefault(); + const customDevices = isBootDeviceSelected ? [] : vgDevices; + onSubmitProp(customDevices); + }; + + const BootDevice = () => { + const bootDevice = devices.find(d => d.name === settings.bootDevice); + + // FIXME: In this case, should be a "readOnly" selector. + return ; + }; + + return ( +
+
+ {_("Devices for creating the volume group")} + + + + +
+ } + else={ + vgDevices?.includes(d.name))} + devices={devices} + onChange={onChangeDevices} + /> + } + /> + + ); +}; + +/** + * Allows to select LVM and configure the system volume group. + * @component + * + * @param {object} props + * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. + * @param {StorageDevice[]} [props.devices=[]] - Available storage devices. + * @param {boolean} [props.isChecked=false] - Whether LVM is selected. + * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading. + * @param {onChangeFn} [props.onChange=noop] - On change callback. + * + * @callback onChangeFn + * @param {boolean} lvm + */ +const LVMField = ({ + settings, + devices = [], + isChecked: isCheckedProp = false, + isLoading = false, + onChange: onChangeProp = noop +}) => { + const [isChecked, setIsChecked] = useState(isCheckedProp); + const [isFormOpen, setIsFormOpen] = useState(false); + const [isFormValid, setIsFormValid] = useState(true); + + const onChange = (_, value) => { + setIsChecked(value); + onChangeProp({ lvm: value, vgDevices: [] }); + }; + + const openForm = () => setIsFormOpen(true); + + const closeForm = () => setIsFormOpen(false); + + const onValidateForm = (valid) => setIsFormValid(valid); + + const onSubmitForm = (vgDevices) => { + closeForm(); + onChangeProp({ vgDevices }); + }; + + const description = _("Configuration of the system volume group. All the file systems will be \ +created in a logical volume of the system volume group."); + + const LVMSettingsButton = () => { + return ( + + + + ); + }; + + if (isLoading) return ; + + return ( +
+ + } /> + + + + + {_("Accept")} + + + + +
+ ); +}; + +/** + * Section for editing the selected device + * @component + * + * @callback onChangeFn + * @param {object} settings + * + * @param {object} props + * @param {ProposalSettings} props.settings + * @param {StorageDevice[]} [props.availableDevices=[]] + * @param {boolean} [isLoading=false] + * @param {onChangeFn} [props.onChange=noop] + */ +export default function ProposalDeviceSection({ + settings, + availableDevices = [], + isLoading = false, + onChange = noop +}) { + // FIXME: we should work with devices objects ASAP + const { bootDevice } = settings; + + const changeBootDevice = (device) => { + if (device.name !== bootDevice) { + onChange({ bootDevice: device.name }); + } + }; + + const changeLVM = ({ lvm, vgDevices }) => { + const settings = {}; + if (lvm !== undefined) settings.lvm = lvm; + if (vgDevices !== undefined) settings.systemVGDevices = vgDevices; + + onChange(settings); + }; + + const Description = () => ( + LVM \ +Volume Group for installation.") + }} + /> + ); + + return ( +
} + > + + +
+ ); +} diff --git a/web/src/components/storage/ProposalDeviceSection.test.jsx b/web/src/components/storage/ProposalDeviceSection.test.jsx new file mode 100644 index 0000000000..73cd460835 --- /dev/null +++ b/web/src/components/storage/ProposalDeviceSection.test.jsx @@ -0,0 +1,396 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProposalDeviceSection } from "~/components/storage"; + +const sda = { + sid: "59", + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const sdb = { + sid: "62", + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +const vda = { + sid: "59", + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/vda", + size: 1024, + systems: ["Windows", "openSUSE Leap 15.2"], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], + partitionTable: { type: "gpt", partitions: [] } +}; + +const md0 = { + sid: "62", + type: "md", + level: "raid0", + uuid: "12345:abcde", + members: ["/dev/vdb"], + active: true, + name: "/dev/md0", + size: 2048, + systems: [], + udevIds: [], + udevPaths: [] +}; + +const md1 = { + sid: "63", + type: "md", + level: "raid0", + uuid: "12345:abcde", + members: ["/dev/vdc"], + active: true, + name: "/dev/md1", + size: 4096, + systems: [], + udevIds: [], + udevPaths: [] +}; + +const props = { + settings: { + bootDevice: "/dev/sda", + }, + availableDevices: [sda, sdb], + isLoading: false, + onChange: jest.fn() +}; + +describe("ProposalDeviceSection", () => { + describe("Installation device field", () => { + describe("when set as loading", () => { + beforeEach(() => { + props.isLoading = true; + }); + + describe("and selected device is not defined yet", () => { + beforeEach(() => { + props.settings = { bootDevice: undefined }; + }); + + it("renders a loading hint", () => { + plainRender(); + screen.getByText("Waiting for information about selected device"); + }); + }); + }); + describe("when installation device is not selected yet", () => { + beforeEach(() => { + props.settings = { bootDevice: "" }; + }); + + it("uses a 'No device selected yet' text for the selection button", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "No device selected yet" }); + + await user.click(button); + + screen.getByRole("dialog", { name: "Installation device" }); + }); + }); + + describe("when installation device is selected", () => { + beforeEach(() => { + props.settings = { bootDevice: "/dev/sda" }; + }); + + it("uses its name as part of the text for the selection button", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /\/dev\/sda/ }); + + await user.click(button); + + screen.getByRole("dialog", { name: "Installation device" }); + }); + }); + + it("allows changing the selected device", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); + + await user.click(button); + + const selector = await screen.findByRole("dialog", { name: "Installation device" }); + const sdbOption = within(selector).getByRole("radio", { name: /sdb/ }); + const accept = within(selector).getByRole("button", { name: "Accept" }); + + await user.click(sdbOption); + await user.click(accept); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(props.onChange).toHaveBeenCalledWith({ bootDevice: sdb.name }); + }); + + it("allows canceling a device selection", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); + + await user.click(button); + + const selector = await screen.findByRole("dialog", { name: "Installation device" }); + const sdbOption = within(selector).getByRole("radio", { name: /sdb/ }); + const cancel = within(selector).getByRole("button", { name: "Cancel" }); + + await user.click(sdbOption); + await user.click(cancel); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(props.onChange).not.toHaveBeenCalled(); + }); + + it("does not trigger the onChange callback when selection actually did not change", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); + + await user.click(button); + + const selector = await screen.findByRole("dialog", { name: "Installation device" }); + const sdaOption = within(selector).getByRole("radio", { name: /sda/ }); + const sdbOption = within(selector).getByRole("radio", { name: /sdb/ }); + const accept = within(selector).getByRole("button", { name: "Accept" }); + + // User selects a different device + await user.click(sdbOption); + // but then goes back to the selected device + await user.click(sdaOption); + // and clicks on Accept button + await user.click(accept); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + // There is no reason for triggering the onChange callback + expect(props.onChange).not.toHaveBeenCalled(); + }); + }); + + describe("LVM field", () => { + describe("if LVM setting is not set yet", () => { + beforeEach(() => { + props.settings = {}; + }); + + it("does not render the LVM switch", () => { + plainRender(); + + expect(screen.queryByLabelText(/Use logical volume/)).toBeNull(); + }); + }); + + describe("if LVM setting is set", () => { + beforeEach(() => { + props.settings = { lvm: false }; + }); + + it("renders the LVM switch", () => { + plainRender(); + + screen.getByRole("checkbox", { name: /Use logical volume/ }); + }); + }); + + describe("if LVM is set to true", () => { + beforeEach(() => { + props.availableDevices = [vda, md0, md1]; + props.settings = { bootDevice: "/dev/vda", lvm: true, systemVGDevices: [] }; + props.onChange = jest.fn(); + }); + + it("renders the LVM switch as selected", () => { + plainRender(); + + const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); + expect(checkbox).toBeChecked(); + }); + + it("renders a button for changing the LVM settings", () => { + plainRender(); + + screen.getByRole("button", { name: /LVM settings/ }); + }); + + it("changes the selection on click", async () => { + const { user } = plainRender(); + + const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); + await user.click(checkbox); + + expect(checkbox).not.toBeChecked(); + expect(props.onChange).toHaveBeenCalled(); + }); + + describe("and user clicks on LVM settings", () => { + it("opens the LVM settings dialog", async () => { + const { user } = plainRender(); + const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); + + await user.click(settingsButton); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("System Volume Group"); + }); + + it("allows selecting either installation device or custom devices", async () => { + const { user } = plainRender(); + const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); + + await user.click(settingsButton); + + const popup = await screen.findByRole("dialog"); + screen.getByText("System Volume Group"); + + within(popup).getByRole("button", { name: "Installation device" }); + within(popup).getByRole("button", { name: "Custom devices" }); + }); + + it("allows to set the installation device as system volume group", async () => { + const { user } = plainRender(); + const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); + + await user.click(settingsButton); + + const popup = await screen.findByRole("dialog"); + screen.getByText("System Volume Group"); + + const bootDeviceButton = within(popup).getByRole("button", { name: "Installation device" }); + const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" }); + const acceptButton = within(popup).getByRole("button", { name: "Accept" }); + + await user.click(customDevicesButton); + await user.click(bootDeviceButton); + await user.click(acceptButton); + + expect(props.onChange).toHaveBeenCalledWith( + expect.objectContaining({ systemVGDevices: [] }) + ); + }); + + it("allows customize the system volume group", async () => { + const { user } = plainRender(); + const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); + + await user.click(settingsButton); + + const popup = await screen.findByRole("dialog"); + screen.getByText("System Volume Group"); + + const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" }); + const acceptButton = within(popup).getByRole("button", { name: "Accept" }); + + await user.click(customDevicesButton); + + const vdaOption = within(popup).getByRole("row", { name: /vda/ }); + const md0Option = within(popup).getByRole("row", { name: /md0/ }); + const md1Option = within(popup).getByRole("row", { name: /md1/ }); + + // unselect the boot devices + await user.click(vdaOption); + + await user.click(md0Option); + await user.click(md1Option); + + await user.click(acceptButton); + + expect(props.onChange).toHaveBeenCalledWith( + expect.objectContaining({ systemVGDevices: ["/dev/md0", "/dev/md1"] }) + ); + }); + }); + }); + + describe("if LVM is set to false", () => { + beforeEach(() => { + props.settings = { lvm: false }; + props.onChange = jest.fn(); + }); + + it("renders the LVM switch as not selected", () => { + plainRender(); + + const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); + expect(checkbox).not.toBeChecked(); + }); + + it("does not render a button for changing the LVM settings", () => { + plainRender(); + + const button = screen.queryByRole("button", { name: /LVM settings/ }); + expect(button).toBeNull(); + }); + + it("changes the selection on click", async () => { + const { user } = plainRender(); + + const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); + await user.click(checkbox); + + expect(checkbox).toBeChecked(); + expect(props.onChange).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/web/src/components/storage/ProposalFileSystemsSection.jsx b/web/src/components/storage/ProposalFileSystemsSection.jsx new file mode 100644 index 0000000000..0b0e5f632d --- /dev/null +++ b/web/src/components/storage/ProposalFileSystemsSection.jsx @@ -0,0 +1,80 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { _ } from "~/i18n"; +import { Section } from "~/components/core"; +import { ProposalVolumes } from "~/components/storage"; +import { noop } from "~/utils"; + +/** + * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").ProposalManager.Volume} Volume + */ + +/** + * Section for editing the proposal file systems + * @component + * + * @callback onChangeFn + * @param {object} settings + * + * @param {object} props + * @param {ProposalSettings} props.settings + * @param {Volume[]} [props.volumeTemplates=[]] + * @param {boolean} [props.isLoading=false] + * @param {onChangeFn} [props.onChange=noop] + * + */ +export default function ProposalFileSystemsSection({ + settings, + volumeTemplates = [], + isLoading = false, + onChange = noop +}) { + const { volumes = [] } = settings; + + const changeVolumes = (volumes) => { + onChange({ volumes }); + }; + + // Templates for already existing mount points are filtered out + const usefulTemplates = () => { + const mountPaths = volumes.map(v => v.mountPath); + return volumeTemplates.filter(t => ( + t.mountPath.length > 0 && !mountPaths.includes(t.mountPath) + )); + }; + + const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; + + return ( +
+ +
+ ); +} diff --git a/web/src/components/storage/ProposalFileSystemsSection.test.jsx b/web/src/components/storage/ProposalFileSystemsSection.test.jsx new file mode 100644 index 0000000000..0b1493a5ff --- /dev/null +++ b/web/src/components/storage/ProposalFileSystemsSection.test.jsx @@ -0,0 +1,55 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProposalFileSystemsSection } from "~/components/storage"; + +const props = { + settings: {}, + isLoading: false, + onChange: jest.fn() +}; + +describe("ProposalFileSystemsSection", () => { + it("renders a section holding file systems related stuff", () => { + plainRender(); + screen.getByRole("region", { name: "File systems" }); + screen.getByRole("grid", { name: /mount points/ }); + }); + + it("requests a volume change when onChange callback is triggered", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Actions" }); + + await user.click(button); + + const menu = screen.getByRole("menu"); + const reset = within(menu).getByRole("menuitem", { name: /Reset/ }); + + await user.click(reset); + + expect(props.onChange).toHaveBeenCalledWith( + { volumes: expect.any(Array) } + ); + }); +}); diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index e354ccfd93..51ab17e7bf 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -30,6 +30,8 @@ import { ProposalPageMenu, ProposalSettingsSection, ProposalSpacePolicySection, + ProposalDeviceSection, + ProposalFileSystemsSection } from "~/components/storage"; import { IDLE } from "~/client/status"; @@ -197,25 +199,27 @@ export default function ProposalPage() { }; const PageContent = () => { - // Templates for already existing mount points are filtered out - const usefulTemplates = () => { - const volumes = state.settings.volumes || []; - const mountPaths = volumes.map(v => v.mountPath); - return state.volumeTemplates.filter(t => ( - t.mountPath.length > 0 && !mountPaths.includes(t.mountPath) - )); - }; - return ( <> + + { - const [device, setDevice] = useState(current || devices[0]); - - const changeSelected = (deviceId) => { - setDevice(devices.find(d => d.sid === deviceId)); - }; - - const submitForm = (e) => { - e.preventDefault(); - if (device !== undefined) onSubmit(device); - }; - - return ( -
- - - ); -}; - -/** - * Allows to select the installation device. - * @component - * - * @param {object} props - * @param {string} [props.current] - Device name, if any. - * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection. - * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading. - * @param {onChangeFn} [props.onChange=noop] - On change callback. - * - * @callback onChangeFn - * @param {string} device - Name of the selected device. - */ -const InstallationDeviceField = ({ - current, - devices = [], - isLoading = false, - onChange = noop -}) => { - const [device, setDevice] = useState(devices.find(d => d.name === current)); - const [isFormOpen, setIsFormOpen] = useState(false); - - const openForm = () => setIsFormOpen(true); - - const closeForm = () => setIsFormOpen(false); - - const acceptForm = (selectedDevice) => { - closeForm(); - setDevice(selectedDevice); - onChange(selectedDevice); - }; - - /** - * Renders a button that allows changing selected device - * - * NOTE: if a device is already selected, its name and size will be used for - * the button text. Otherwise, a "No device selected" text will be shown. - * - * @param {object} props - * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any. - */ - const DeviceContent = ({ device }) => { - return ( - - ); - }; - - if (isLoading) { - return ; - } - - const description = _("Select the device for installing the system."); - - return ( - <> -
- {_("Installation device")} - -
- - - } - /> - - - {_("Accept")} - - - - - - ); -}; - -/** - * Form for configuring the system volume group. - * @component - * - * @param {object} props - * @param {string} props.id - Form ID. - * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. - * @param {StorageDevice[]} [props.devices=[]] - Available storage devices. - * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback. - * @param {onValidateFn} [props.onValidate=noop] - On validate callback. - * - * @callback onSubmitFn - * @param {string[]} devices - Name of the selected devices. - * - * @callback onValidateFn - * @param {boolean} valid - */ -const LVMSettingsForm = ({ - id, - settings, - devices = [], - onSubmit: onSubmitProp = noop, - onValidate = noop -}) => { - const [vgDevices, setVgDevices] = useState(settings.systemVGDevices); - const [isBootDeviceSelected, setIsBootDeviceSelected] = useState(settings.systemVGDevices.length === 0); - const [editedDevices, setEditedDevices] = useState(false); - - const selectBootDevice = () => { - setIsBootDeviceSelected(true); - onValidate(true); - }; - - const selectCustomDevices = () => { - setIsBootDeviceSelected(false); - const { bootDevice } = settings; - const customDevices = (vgDevices.length === 0 && !editedDevices) ? [bootDevice] : vgDevices; - setVgDevices(customDevices); - onValidate(customDevices.length > 0); - }; - - const onChangeDevices = (selection) => { - const selectedDevices = devices.filter(d => selection.includes(d.sid)).map(d => d.name); - setVgDevices(selectedDevices); - setEditedDevices(true); - onValidate(devices.length > 0); - }; - - const onSubmit = (e) => { - e.preventDefault(); - const customDevices = isBootDeviceSelected ? [] : vgDevices; - onSubmitProp(customDevices); - }; - - const BootDevice = () => { - const bootDevice = devices.find(d => d.name === settings.bootDevice); - - // FIXME: In this case, should be a "readOnly" selector. - return ; - }; - - return ( -
-
- {_("Devices for creating the volume group")} - - - - -
- } - else={ - vgDevices?.includes(d.name))} - devices={devices} - onChange={onChangeDevices} - /> - } - /> - - ); -}; - -/** - * Allows to select LVM and configure the system volume group. - * @component - * - * @param {object} props - * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. - * @param {StorageDevice[]} [props.devices=[]] - Available storage devices. - * @param {boolean} [props.isChecked=false] - Whether LVM is selected. - * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading. - * @param {onChangeFn} [props.onChange=noop] - On change callback. - * - * @callback onChangeFn - * @param {boolean} lvm - */ -const LVMField = ({ - settings, - devices = [], - isChecked: isCheckedProp = false, - isLoading = false, - onChange: onChangeProp = noop -}) => { - const [isChecked, setIsChecked] = useState(isCheckedProp); - const [isFormOpen, setIsFormOpen] = useState(false); - const [isFormValid, setIsFormValid] = useState(true); - - const onChange = (_, value) => { - setIsChecked(value); - onChangeProp({ lvm: value, vgDevices: [] }); - }; - - const openForm = () => setIsFormOpen(true); - - const closeForm = () => setIsFormOpen(false); - - const onValidateForm = (valid) => setIsFormValid(valid); - - const onSubmitForm = (vgDevices) => { - closeForm(); - onChangeProp({ vgDevices }); - }; - - const description = _("Configuration of the system volume group. All the file systems will be \ -created in a logical volume of the system volume group."); - - const LVMSettingsButton = () => { - return ( - - - - ); - }; - - if (isLoading) return ; - - return ( -
- - } /> - - - - - {_("Accept")} - - - - -
- ); -}; - /** * Form for configuring the encryption password. * @component @@ -558,10 +222,7 @@ const EncryptionField = ({ * * @param {object} props * @param {ProposalSettings} props.settings - * @param {StorageDevice[]} [props.availableDevices=[]] - * @param {Volume[]} [props.volumeTemplates=[]] * @param {String[]} [props.encryptionMethods=[]] - * @param {boolean} [isLoading=false] * @param {onChangeFn} [props.onChange=noop] * * @callback onChangeFn @@ -569,52 +230,18 @@ const EncryptionField = ({ */ export default function ProposalSettingsSection({ settings, - availableDevices = [], - volumeTemplates = [], encryptionMethods = [], - isLoading = false, onChange = noop }) { - // FIXME: we should work with devices objects ASAP - const changeBootDevice = (device) => { - onChange({ bootDevice: device.name }); - }; - - const changeLVM = ({ lvm, vgDevices }) => { - const settings = {}; - if (lvm !== undefined) settings.lvm = lvm; - if (vgDevices !== undefined) settings.systemVGDevices = vgDevices; - - onChange(settings); - }; - const changeEncryption = ({ password, method }) => { onChange({ encryptionPassword: password, encryptionMethod: method }); }; - const changeVolumes = (volumes) => { - onChange({ volumes }); - }; - - const { bootDevice } = settings; const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; return ( <> -
- - +
-
); diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.jsx index a3687f6047..50de9dac00 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.jsx @@ -35,333 +35,10 @@ jest.mock("@patternfly/react-core", () => { let props; -const vda = { - sid: "59", - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/vda", - size: 1024, - systems: ["Windows", "openSUSE Leap 15.2"], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - partitionTable: { type: "gpt", partitions: [] } -}; - -const md0 = { - sid: "62", - type: "md", - level: "raid0", - uuid: "12345:abcde", - members: ["/dev/vdb"], - active: true, - name: "/dev/md0", - size: 2048, - systems: [], - udevIds: [], - udevPaths: [] -}; - -const md1 = { - sid: "63", - type: "md", - level: "raid0", - uuid: "12345:abcde", - members: ["/dev/vdc"], - active: true, - name: "/dev/md1", - size: 4096, - systems: [], - udevIds: [], - udevPaths: [] -}; - beforeEach(() => { props = {}; }); -describe("Installation device field", () => { - describe("if it is loading", () => { - beforeEach(() => { - props.isLoading = true; - }); - - describe("and there is no selected device yet", () => { - beforeEach(() => { - props.settings = { bootDevice: "" }; - }); - - it("renders a message indicating that the device is not selected", () => { - plainRender(); - - screen.getByText(/Installation device/); - screen.getByText(/No device selected/); - }); - }); - - // FIXME: skipping this test example by now. - // It will be addressed when reworking the Device section - // as part of https://github.com/openSUSE/agama/pull/1031 - describe.skip("and there is a selected device", () => { - beforeEach(() => { - props.settings = { bootDevice: "/dev/vda" }; - }); - - it("renders the selected device", () => { - plainRender(); - - screen.getByText(/Installation device/); - screen.getByText("/dev/vda"); - }); - }); - }); - - describe("if there is no selected device yet", () => { - beforeEach(() => { - props.settings = { bootDevice: "" }; - }); - - it("renders a message indicating that the device is not selected", () => { - plainRender(); - - screen.getByText(/Installation device/); - screen.getByText(/No device selected/); - }); - }); - - // FIXME: skipping this test example by now. - // It will be addressed when reworking the Device section - // as part of https://github.com/openSUSE/agama/pull/1031 - describe.skip("if there is a selected device", () => { - beforeEach(() => { - props.settings = { bootDevice: "/dev/vda" }; - }); - - it("renders the selected device", () => { - plainRender(); - - screen.getByText(/Installation device/); - screen.getByText("/dev/vda"); - }); - }); - - it("allows selecting a device when clicking on the device name", async () => { - props = { - availableDevices: [vda], - settings: { bootDevice: "/dev/vda" }, - onChange: jest.fn() - }; - - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: "/dev/vda, 1 KiB" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Installation device"); - - const accept = within(popup).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).toHaveBeenCalled(); - }); - - it("allows canceling the selection of the device", async () => { - props = { - availableDevices: [vda], - settings: { bootDevice: "/dev/vda" }, - onChange: jest.fn() - }; - - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: "/dev/vda, 1 KiB" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Installation device"); - - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); - }); -}); - -describe("LVM field", () => { - describe("if LVM setting is not set yet", () => { - beforeEach(() => { - props.settings = {}; - }); - - it("does not render the LVM switch", () => { - plainRender(); - - expect(screen.queryByLabelText(/Use logical volume/)).toBeNull(); - }); - }); - - describe("if LVM setting is set", () => { - beforeEach(() => { - props.settings = { lvm: false }; - }); - - it("renders the LVM switch", () => { - plainRender(); - - screen.getByRole("checkbox", { name: /Use logical volume/ }); - }); - }); - - describe("if LVM is set to true", () => { - beforeEach(() => { - props.availableDevices = [vda, md0, md1]; - props.settings = { bootDevice: "/dev/vda", lvm: true, systemVGDevices: [] }; - props.onChange = jest.fn(); - }); - - it("renders the LVM switch as selected", () => { - plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - expect(checkbox).toBeChecked(); - }); - - it("renders a button for changing the LVM settings", () => { - plainRender(); - - screen.getByRole("button", { name: /LVM settings/ }); - }); - - it("changes the selection on click", async () => { - const { user } = plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - await user.click(checkbox); - - expect(checkbox).not.toBeChecked(); - expect(props.onChange).toHaveBeenCalled(); - }); - - describe("and user clicks on LVM settings", () => { - it("opens the LVM settings dialog", async () => { - const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("System Volume Group"); - }); - - it("allows selecting either installation device or custom devices", async () => { - const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - screen.getByText("System Volume Group"); - - within(popup).getByRole("button", { name: "Installation device" }); - within(popup).getByRole("button", { name: "Custom devices" }); - }); - - it("allows to set the installation device as system volume group", async () => { - const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - screen.getByText("System Volume Group"); - - const bootDeviceButton = within(popup).getByRole("button", { name: "Installation device" }); - const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" }); - const acceptButton = within(popup).getByRole("button", { name: "Accept" }); - - await user.click(customDevicesButton); - await user.click(bootDeviceButton); - await user.click(acceptButton); - - expect(props.onChange).toHaveBeenCalledWith( - expect.objectContaining({ systemVGDevices: [] }) - ); - }); - - it("allows customize the system volume group", async () => { - const { user } = plainRender(); - const settingsButton = screen.getByRole("button", { name: /LVM settings/ }); - - await user.click(settingsButton); - - const popup = await screen.findByRole("dialog"); - screen.getByText("System Volume Group"); - - const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" }); - const acceptButton = within(popup).getByRole("button", { name: "Accept" }); - - await user.click(customDevicesButton); - - const vdaOption = within(popup).getByRole("row", { name: /vda/ }); - const md0Option = within(popup).getByRole("row", { name: /md0/ }); - const md1Option = within(popup).getByRole("row", { name: /md1/ }); - - // unselect the boot devices - await user.click(vdaOption); - - await user.click(md0Option); - await user.click(md1Option); - - await user.click(acceptButton); - - expect(props.onChange).toHaveBeenCalledWith( - expect.objectContaining({ systemVGDevices: ["/dev/md0", "/dev/md1"] }) - ); - }); - }); - }); - - describe("if LVM is set to false", () => { - beforeEach(() => { - props.settings = { lvm: false }; - props.onChange = jest.fn(); - }); - - it("renders the LVM switch as not selected", () => { - plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - expect(checkbox).not.toBeChecked(); - }); - - it("does not render a button for changing the LVM settings", () => { - plainRender(); - - const button = screen.queryByRole("button", { name: /LVM settings/ }); - expect(button).toBeNull(); - }); - - it("changes the selection on click", async () => { - const { user } = plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ }); - await user.click(checkbox); - - expect(checkbox).toBeChecked(); - expect(props.onChange).toHaveBeenCalled(); - }); - }); -}); - describe("Encryption field", () => { describe("if encryption password setting is not set yet", () => { beforeEach(() => { diff --git a/web/src/components/storage/ProposalSpacePolicySection.jsx b/web/src/components/storage/ProposalSpacePolicySection.jsx index 9c2d65fc45..c08135c0e5 100644 --- a/web/src/components/storage/ProposalSpacePolicySection.jsx +++ b/web/src/components/storage/ProposalSpacePolicySection.jsx @@ -447,7 +447,13 @@ export default function ProposalSpacePolicySection({ const currentPolicy = SPACE_POLICIES.find(p => p.id === settings.spacePolicy) || SPACE_POLICIES[0]; return ( -
+
} diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index d9f16334c0..8b6398b43a 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -404,9 +404,6 @@ export default function ProposalVolumes({ <> - - {_("File systems to create in your system")} -