diff --git a/web/src/components/storage/ProposalFileSystemsSection.jsx b/web/src/components/storage/ProposalFileSystemsSection.jsx deleted file mode 100644 index 0b0e5f632d..0000000000 --- a/web/src/components/storage/ProposalFileSystemsSection.jsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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 deleted file mode 100644 index 0b1493a5ff..0000000000 --- a/web/src/components/storage/ProposalFileSystemsSection.test.jsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 85e29c5b41..c3d4753623 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -29,9 +29,7 @@ import { ProposalActionsSection, ProposalPageMenu, ProposalSettingsSection, - ProposalSpacePolicySection, ProposalDeviceSection, - ProposalFileSystemsSection, ProposalTransactionalInfo } from "~/components/storage"; import { IDLE } from "~/client/status"; @@ -233,17 +231,7 @@ export default function ProposalPage() { - - { await screen.findByText(/\/dev\/vda/); }); -it("renders the settings, find space and actions sections", async () => { +it("renders the device, settings and actions sections", async () => { installerRender(); + await screen.findByText(/Device/); await screen.findByText(/Settings/); - await screen.findByText(/Find Space/); await screen.findByText(/Planned Actions/); }); diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index abc6830308..969d862b1f 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -24,6 +24,7 @@ import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-cor import { _ } from "~/i18n"; import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core"; +import { ProposalVolumes, ProposalSpacePolicyField } from "~/components/storage"; import { Icon } from "~/components/layout"; import { noop } from "~/utils"; import { hasFS } from "~/components/storage/utils"; @@ -283,6 +284,8 @@ const EncryptionField = ({ export default function ProposalSettingsSection({ settings, encryptionMethods = [], + volumeTemplates = [], + isLoading = false, onChange = noop }) { const changeEncryption = ({ password, method }) => { @@ -302,8 +305,26 @@ export default function ProposalSettingsSection({ onChange({ volumes: settings.volumes }); }; + const changeVolumes = (volumes) => { + onChange({ volumes }); + }; + + const changeSpacePolicy = (policy, actions) => { + onChange({ spacePolicy: policy, spaceActions: actions }); + }; + const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; + const { volumes = [] } = settings; + + // 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) + )); + }; + return ( <>
@@ -319,6 +340,20 @@ export default function ProposalSettingsSection({ isLoading={settings.encryptionPassword === undefined} onChange={changeEncryption} /> + +
); diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.jsx index 3077cd7903..29f7fc4723 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.jsx @@ -36,7 +36,10 @@ jest.mock("@patternfly/react-core", () => { let props; beforeEach(() => { - props = {}; + props = { + settings: {}, + onChange: jest.fn() + }; }); const rootVolume = { mountPath: "/", fsType: "Btrfs", outline: { snapshotsConfigurable: true } }; @@ -65,6 +68,28 @@ describe("if snapshots are not configurable", () => { }); }); +it("renders a section holding file systems related stuff", () => { + plainRender(); + screen.getByRole("grid", { name: "Table with mount points" }); + 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) } + ); +}); + 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/ProposalSpacePolicyField.jsx similarity index 54% rename from web/src/components/storage/ProposalSpacePolicySection.jsx rename to web/src/components/storage/ProposalSpacePolicyField.jsx index c08135c0e5..17455afb4b 100644 --- a/web/src/components/storage/ProposalSpacePolicySection.jsx +++ b/web/src/components/storage/ProposalSpacePolicyField.jsx @@ -19,13 +19,13 @@ * find current contact information at www.suse.com. */ -import React, { useEffect } from "react"; -import { FormSelect, FormSelectOption } from "@patternfly/react-core"; +import React, { useEffect, useState } from "react"; +import { Button, Form, FormSelect, FormSelectOption, Skeleton } from "@patternfly/react-core"; -import { _, N_ } from "~/i18n"; +import { _, N_, n_ } from "~/i18n"; import { deviceSize } from '~/components/storage/utils'; -import { If, OptionsPicker, Section, SectionSkeleton } from "~/components/core"; -import { noop, useLocalStorage } from "~/utils"; +import { If, OptionsPicker, Popup, SectionSkeleton } from "~/components/core"; +import { noop } from "~/utils"; import { sprintf } from "sprintf-js"; import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table'; @@ -48,31 +48,57 @@ const SPACE_POLICIES = [ { id: "delete", label: N_("Delete current content"), - description: N_("All partitions will be removed and any data in the disks will be lost.") + description: N_("All partitions will be removed and any data in the disks will be lost."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space deleting all content[...]" + N_("deleting all content of the installation device"), + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space deleting all content[...]" + N_("deleting all content of the %d selected disks") + ] }, { id: "resize", label: N_("Shrink existing partitions"), - description: N_("The data is kept, but the current partitions will be resized as needed.") + description: N_("The data is kept, but the current partitions will be resized as needed."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space shrinking partitions[...]" + N_("shrinking partitions of the installation device"), + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space shrinking partitions[...]" + N_("shrinking partitions of the %d selected disks") + ] }, { id: "keep", label: N_("Use available space"), - description: N_("The data is kept. Only the space not assigned to any partition will be used.") + description: N_("The data is kept. Only the space not assigned to any partition will be used."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space without modifying any partition". + N_("without modifying any partition") + ] }, { id: "custom", label: N_("Custom"), - description: N_("Select what to do with each partition.") + description: N_("Select what to do with each partition."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space performing a custom set of actions". + N_("performing a custom set of actions") + ] } ]; // Names of the columns for the policy actions. const columnNames = { device: N_("Used device"), - content: N_("Current content"), + content: N_("Details"), size: N_("Size"), - details: N_("Details"), + details: N_("Size details"), action: N_("Action") }; @@ -84,63 +110,23 @@ const columnNames = { * @param {StorageDevice} props.device */ const DeviceDescriptionColumn = ({ device }) => { - return ( - <> -
{device.name}
- {`${device.vendor} ${device.model}`}} - /> - - ); + if (device.isDrive || device.type === "lvmVg") return device.name; + + return device.name.split("/").pop(); }; /** - * Column content with information about the current content of the device. + * Column content with details about the device. * @component * * @param {object} props * @param {StorageDevice} props.device */ const DeviceContentColumn = ({ device }) => { - const PartitionTableContent = () => { - return ( -
- {/* TRANSLATORS: %s is replaced by partition table type (e.g., GPT) */} - {sprintf(_("%s partition table"), device.partitionTable.type.toUpperCase())} -
- ); - }; - - const BlockContent = () => { - const renderContent = () => { - const systems = device.systems; - if (systems.length > 0) return systems.join(", "); - - const filesystem = device.filesystem; - if (filesystem?.isEFI) return _("EFI system partition"); - if (filesystem) { - // TRANSLATORS: %s is replaced by a file system type (e.g., btrfs). - return sprintf(_("%s file system"), filesystem?.type); - } - - const component = device.component; - switch (component?.type) { - case "physical_volume": - // TRANSLATORS: %s is replaced by a LVM volume group name (e.g., /dev/vg0). - return sprintf(_("LVM physical volume of %s"), component.deviceNames[0]); - case "md_device": - // TRANSLATORS: %s is replaced by a RAID name (e.g., /dev/md0). - return sprintf(_("Member of RAID %s"), component.deviceNames[0]); - default: - return _("Not identified"); - } - }; - - return
{renderContent()}
; - }; + const systems = device.systems; + if (systems.length > 0) return systems.join(", "); - return (device.partitionTable ? : ); + return device.description; }; /** @@ -151,7 +137,7 @@ const DeviceContentColumn = ({ device }) => { * @param {StorageDevice} props.device */ const DeviceSizeColumn = ({ device }) => { - return
{deviceSize(device.size)}
; + return deviceSize(device.size); }; /** @@ -166,26 +152,16 @@ const DeviceDetailsColumn = ({ device }) => { if (device.filesystem) return null; const unused = device.partitionTable?.unpartitionedSize || 0; - - return ( -
- {/* TRANSLATORS: %s is replaced by a disk size (e.g., 20 GiB) */} - {sprintf(_("%s unused"), deviceSize(unused))} -
- ); + // TRANSLATORS: %s is replaced by a disk size (e.g., 20 GiB) + return sprintf(_("%s unused"), deviceSize(unused)); }; const RecoverableSize = () => { const size = device.recoverableSize; - if (size === 0) return null; - return ( -
- {/* TRANSLATORS: %s is replaced by a disk size (e.g., 2 GiB) */} - {sprintf(_("Shrinkable by %s"), deviceSize(device.recoverableSize))} -
- ); + // TRANSLATORS: %s is replaced by a disk size (e.g., 2 GiB) + return sprintf(_("Shrinkable by %s"), deviceSize(device.recoverableSize)); }; return ( @@ -250,7 +226,8 @@ const DeviceActionColumn = ({ device, action, isDisabled = false, onChange = noo */ const DeviceRow = ({ device, - settings, + policy, + actions, rowIndex, level = 1, setSize = 0, @@ -260,6 +237,18 @@ const DeviceRow = ({ onCollapse = noop, onChange = noop }) => { + // Generates the action value according to the policy. + const action = () => { + if (policy.id === "custom") + return actions.find(a => a.device === device.name)?.action || "keep"; + + const policyAction = { delete: "force_delete", resize: "resize", keep: "keep" }; + return policyAction[policy.id]; + }; + + const isDisabled = policy.id !== "custom"; + const showAction = !device.partitionTable; + const treeRow = { onCollapse, rowIndex, @@ -273,31 +262,29 @@ const DeviceRow = ({ } }; - const spaceAction = settings.spaceActions.find(a => a.device === device.name); - const isDisabled = settings.spacePolicy !== "custom"; - const showAction = !device.partitionTable; - return ( - + {/* eslint-disable agama-i18n/string-literals */} + - - - - + + + + } /> + {/* eslint-enable agama-i18n/string-literals */} ); }; @@ -310,31 +297,30 @@ const DeviceRow = ({ * @param {ProposalSettings} props.settings * @param {(action: SpaceAction) => void} [props.onChange] */ -const SpaceActionsTable = ({ settings, onChange = noop }) => { - const [expandedDevices, setExpandedDevices] = useLocalStorage("storage-space-actions-expanded", []); - const [autoExpanded, setAutoExpanded] = useLocalStorage("storage-space-actions-auto-expanded", false); +const SpaceActionsTable = ({ policy, actions, devices, onChange = noop }) => { + const [expandedDevices, setExpandedDevices] = useState([]); + const [autoExpanded, setAutoExpanded] = useState(false); useEffect(() => { - const policy = settings.spacePolicy; - const devices = settings.installationDevices.map(d => d.name); - let currentExpanded = devices.filter(d => expandedDevices.includes(d)); + const devNames = devices.map(d => d.name); + let currentExpanded = devNames.filter(d => expandedDevices.includes(d)); - if (policy === "custom" && !autoExpanded) { - currentExpanded = [...devices]; + if (policy.id === "custom" && !autoExpanded) { + currentExpanded = [...devNames]; setAutoExpanded(true); - } else if (policy !== "custom" && autoExpanded) { + } else if (policy.id !== "custom" && autoExpanded) { setAutoExpanded(false); } if (currentExpanded.sort().toString() !== expandedDevices.sort().toString()) { setExpandedDevices(currentExpanded); } - }, [autoExpanded, expandedDevices, setAutoExpanded, setExpandedDevices, settings]); + }, [autoExpanded, expandedDevices, setAutoExpanded, setExpandedDevices, policy, devices]); const renderRows = () => { const rows = []; - settings.installationDevices?.forEach((device, index) => { + devices?.forEach((device, index) => { const isExpanded = expandedDevices.includes(device.name); const children = device.partitionTable?.partitions; @@ -348,7 +334,8 @@ const SpaceActionsTable = ({ settings, onChange = noop }) => { { { const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { return ( + {/* eslint-disable agama-i18n/string-literals */} {SPACE_POLICIES.map((policy) => { return ( onChange(policy.id)} + title={_(policy.label)} + body={_(policy.description)} + onClick={() => onChange(policy)} isSelected={currentPolicy?.id === policy.id} /> ); })} + {/* eslint-enable agama-i18n/string-literals */} ); }; @@ -428,45 +418,137 @@ const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { * @param {boolean} [isLoading=false] * @param {(settings: ProposalSettings) => void} [onChange] */ -export default function ProposalSpacePolicySection({ - settings, +const SpacePolicyForm = ({ + id, + currentPolicy, + currentActions, + devices, isLoading = false, - onChange = noop -}) { - const changeSpacePolicy = (policy) => { - onChange({ spacePolicy: policy }); - }; + onSubmit = noop +}) => { + const [policy, setPolicy] = useState(currentPolicy); + const [actions, setActions] = useState(currentActions); + const [customUsed, setCustomUsed] = useState(false); - const changeSpaceActions = (spaceAction) => { - const spaceActions = settings.spaceActions.filter(a => a.device !== spaceAction.device); + // The selectors for the space action have to be initialized always to the same value + // (e.g., "keep") when the custom policy is selected for first time. The following two useEffect + // ensures that. + + // Stores whether the custom policy has been used. + useEffect(() => { + if (policy.id === "custom" && !customUsed) setCustomUsed(true); + }, [policy, customUsed, setCustomUsed]); + + // Resets actions (i.e., sets everything to "keep") if the custom policy has not been used yet. + useEffect(() => { + if (policy.id !== "custom" && !customUsed) setActions([]); + }, [policy, customUsed, setActions]); + + const changeActions = (spaceAction) => { + const spaceActions = actions.filter(a => a.device !== spaceAction.device); if (spaceAction.action !== "keep") spaceActions.push(spaceAction); - onChange({ spaceActions }); + setActions(spaceActions); }; - const currentPolicy = SPACE_POLICIES.find(p => p.id === settings.spacePolicy) || SPACE_POLICIES[0]; + const submitForm = (e) => { + e.preventDefault(); + if (policy !== undefined) onSubmit(policy, actions); + }; return ( -
+
} else={ <> - + 0} - then={} + condition={devices.length > 0} + then={ + + } /> } /> -
+ + ); +}; + +const SpacePolicyButton = ({ policy, devices, onClick = noop }) => { + const Text = () => { + // eslint-disable-next-line agama-i18n/string-literals + if (policy.summaryLabels.length === 1) return _(policy.summaryLabels[0]); + + // eslint-disable-next-line agama-i18n/string-literals + return sprintf(n_(policy.summaryLabels[0], policy.summaryLabels[1], devices.length), devices.length); + }; + + return ; +}; + +export default function ProposalSpacePolicyField({ + policy, + actions = [], + devices = [], + isLoading = false, + onChange = noop +}) { + const spacePolicy = SPACE_POLICIES.find(p => p.id === policy) || SPACE_POLICIES[0]; + const [isFormOpen, setIsFormOpen] = useState(false); + + const openForm = () => setIsFormOpen(true); + const closeForm = () => setIsFormOpen(false); + + const acceptForm = (spacePolicy, actions) => { + closeForm(); + onChange(spacePolicy.id, actions); + }; + + if (isLoading) { + return ; + } + + const description = _("Allocating the file systems might need to find free space \ +in the devices listed below. Choose how to do it."); + + return ( +
+ {/* TRANSLATORS: To be completed with the rest of a sentence like "deleting all content" */} + {_("Find space")} + + +
+ +
+ + + {_("Accept")} + + + +
+
); } diff --git a/web/src/components/storage/ProposalSpacePolicyField.test.jsx b/web/src/components/storage/ProposalSpacePolicyField.test.jsx new file mode 100644 index 0000000000..a8b5bc9bc8 --- /dev/null +++ b/web/src/components/storage/ProposalSpacePolicyField.test.jsx @@ -0,0 +1,359 @@ +/* + * 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, resetLocalStorage } from "~/test-utils"; +import { ProposalSpacePolicyField } 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 sda1 = { + sid: "60", + isDrive: false, + type: "", + active: true, + name: "/dev/sda1", + size: 512, + recoverableSize: 128, + systems : [], + udevIds: [], + udevPaths: [] +}; + +const sda2 = { + sid: "61", + isDrive: false, + type: "", + active: true, + name: "/dev/sda2", + size: 512, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: [] +}; + +sda.partitionTable = { + type: "gpt", + partitions: [sda1, sda2], + unpartitionedSize: 512 +}; + +const sdb = { + sid: "62", + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +let policy; +let devices; +let actions; + +const openPopup = async (props = {}) => { + const allProps = { policy, devices, actions, ...props }; + const { user } = plainRender(); + const button = screen.getByRole("button"); + + await user.click(button); + const dialog = screen.getByRole("dialog", { name: "Find Space" }); + return { user, dialog }; +}; + +const expandRow = async (user, dialog, name) => { + const row = within(dialog).getByRole("row", { name }); + const toggler = within(row).getByRole("button", { name: /expand/i }); + await user.click(toggler); +}; + +const checkSpaceActions = async (deviceActions) => { + deviceActions.forEach(({ name, action }) => { + const row = screen.getByRole("row", { name }); + const selector = within(row).getByRole("combobox", { name }); + within(selector).getByRole("option", { name: action, selected: true }); + }); +}; + +beforeEach(() => { + devices = [sda, sdb]; + policy = "keep"; + actions = [ + { device: "/dev/sda1", action: "force_delete" }, + { device: "/dev/sda2", action: "resize" } + ]; + + resetLocalStorage(); +}); + +describe("ProposalSpacePolicyField", () => { + it("renders a button for opening the space policy dialog", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button"); + + await user.click(button); + + screen.getByRole("dialog", { name: "Find Space" }); + }); + + it("renders the button with a text according to given policy", () => { + const { rerender } = plainRender(); + screen.getByRole("button", { name: /deleting/ }); + rerender(); + screen.getByRole("button", { name: /shrinking/ }); + }); + + describe("within the dialog", () => { + it("renders the space policy picker", async () => { + const { dialog } = await openPopup(); + const picker = within(dialog).getByRole("listbox"); + within(picker).getByRole("option", { name: /delete/i }); + within(picker).getByRole("option", { name: /resize/i }); + within(picker).getByRole("option", { name: /available/i }); + within(picker).getByRole("option", { name: /custom/i }); + }); + + describe("when there are no installation devices", () => { + beforeEach(() => { + devices = []; + }); + + it("does not render the policy actions", async () => { + const { dialog } = await openPopup(); + const actionsTree = within(dialog).queryByRole("treegrid", { name: "Actions to find space" }); + expect(actionsTree).toBeNull(); + }); + }); + + describe("when there are installation devices", () => { + it("renders the policy actions", async () => { + const { dialog } = await openPopup(); + within(dialog).getByRole("treegrid", { name: "Actions to find space" }); + }); + }); + + describe.each([ + { id: 'delete', nameRegexp: /delete/i }, + { id: 'resize', nameRegexp: /shrink/i }, + { id: 'keep', nameRegexp: /the space not assigned/i } + ])("when space policy is '$id'", ({ id, nameRegexp }) => { + beforeEach(() => { + policy = id; + }); + + it("only renders '$id' option as selected", async () => { + const { dialog } = await openPopup(); + const picker = within(dialog).getByRole("listbox"); + within(picker).getByRole("option", { name: nameRegexp, selected: true }); + expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); + }); + + it("does not allow to modify the space actions", async () => { + const { dialog } = await openPopup(); + // NOTE: HTML `disabled` attribute removes the element from the a11y tree. + // That's why the test is using `hidden: true` here to look for disabled actions. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled + // https://testing-library.com/docs/queries/byrole/#hidden + // TODO: use a more inclusive way to disable the actions. + // https://css-tricks.com/making-disabled-buttons-more-inclusive/ + const spaceActions = within(dialog).getAllByRole("combobox", { name: /Space action selector/, hidden: true }); + expect(spaceActions.length).toEqual(3); + }); + }); + + describe("when space policy is 'custom'", () => { + beforeEach(() => { + policy = "custom"; + }); + + it("only renders 'custom' option as selected", async () => { + const { dialog } = await openPopup(); + const picker = within(dialog).getByRole("listbox"); + within(picker).getByRole("option", { name: /custom/i, selected: true }); + expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); + }); + + it("allows to modify the space actions", async () => { + const { dialog } = await openPopup(); + const spaceActions = within(dialog).getAllByRole("combobox", { name: /Space action selector/ }); + expect(spaceActions.length).toEqual(3); + }); + }); + + describe("DeviceActionColumn", () => { + it("renders the space actions selector for devices without partition table", async () => { + const { dialog } = await openPopup(); + const sdaRow = within(dialog).getByRole("row", { name: /sda/ }); + const sdaActionsSelector = within(sdaRow).queryByRole("combobox", { name: "Space action selector for /dev/sda" }); + // sda has partition table, the selector shouldn't be found + expect(sdaActionsSelector).toBeNull(); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + // sdb does not have partition table, selector should be there + within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); + }); + + it("does not renders the 'resize' option for drives", async () => { + const { dialog } = await openPopup(); + const sdbRow = within(dialog).getByRole("row", { name: /sdb/ }); + const spaceActionsSelector = within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); + const resizeOption = within(spaceActionsSelector).queryByRole("option", { name: /resize/ }); + expect(resizeOption).toBeNull(); + }); + + it("renders the 'resize' option for devices other than drives", async () => { + const { user, dialog } = await openPopup(); + const sdaRow = within(dialog).getByRole("row", { name: /sda/ }); + const sdaToggler = within(sdaRow).getByRole("button", { name: /expand/i }); + await user.click(sdaToggler); + const sda1Row = screen.getByRole("row", { name: /sda1/ }); + const spaceActionsSelector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); + within(spaceActionsSelector).getByRole("option", { name: /resize/ }); + }); + + describe("when space policy is 'delete'", () => { + beforeEach(() => { + policy = "delete"; + }); + + it("renders as selected the delete option", async () => { + const { user, dialog } = await openPopup(); + await expandRow(user, dialog, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /delete/i }, + { name: /sda2/, action: /delete/i } + ]); + }); + }); + + describe("when space policy is 'resize'", () => { + beforeEach(() => { + policy = "resize"; + }); + + it("renders as selected the resize option", async () => { + const { user, dialog } = await openPopup(); + await expandRow(user, dialog, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /resize/i }, + { name: /sda2/, action: /resize/i } + ]); + }); + }); + + describe("when space policy is 'keep'", () => { + beforeEach(() => { + policy = "keep"; + }); + + it("renders as selected the keep option", async () => { + const { user, dialog } = await openPopup(); + await expandRow(user, dialog, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /not modify/i }, + { name: /sda2/, action: /not modify/i } + ]); + }); + }); + + describe("when space policy is 'custom'", () => { + beforeEach(() => { + policy = "custom"; + }); + + it("renders as selected the option matching the given device space action", async () => { + await openPopup(); + await checkSpaceActions([ + { name: /sda1/, action: /delete/i }, + { name: /sda2/, action: /resize/i } + ]); + }); + }); + }); + }); + + it("triggers the onChange callback when user accepts the dialog", async () => { + const onChangeFn = jest.fn(); + const { user, dialog } = await openPopup({ onChange: onChangeFn }); + + // Select 'custom' + const picker = within(dialog).getByRole("listbox"); + await user.selectOptions( + picker, + within(picker).getByRole("option", { name: /custom/i }) + ); + + // Select custom actions + const sda1Row = within(dialog).getByRole("row", { name: /sda1/ }); + const sda1Select = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); + await user.selectOptions( + sda1Select, + within(sda1Select).getByRole("option", { name: /delete/i }) + ); + const sda2Row = within(dialog).getByRole("row", { name: /sda2/ }); + const sda2Select = within(sda2Row).getByRole("combobox", { name: "Space action selector for /dev/sda2" }); + await user.selectOptions( + sda2Select, + within(sda2Select).getByRole("option", { name: /resize/i }) + ); + + // Accept the result + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(onChangeFn).toHaveBeenCalledWith( + "custom", + expect.arrayContaining([{ action: "resize", device: "/dev/sda2" }, { action: "force_delete", device: "/dev/sda1" }]) + ); + }); +}); diff --git a/web/src/components/storage/ProposalSpacePolicySection.test.jsx b/web/src/components/storage/ProposalSpacePolicySection.test.jsx deleted file mode 100644 index 07f842ab8b..0000000000 --- a/web/src/components/storage/ProposalSpacePolicySection.test.jsx +++ /dev/null @@ -1,266 +0,0 @@ -/* - * 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, resetLocalStorage } from "~/test-utils"; -import { ProposalSpacePolicySection } 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 sda1 = { - sid: "60", - isDrive: false, - type: "", - active: true, - name: "/dev/sda1", - size: 512, - recoverableSize: 128, - systems : [], - udevIds: [], - udevPaths: [] -}; - -const sda2 = { - sid: "61", - isDrive: false, - type: "", - active: true, - name: "/dev/sda2", - size: 512, - recoverableSize: 0, - systems : [], - udevIds: [], - udevPaths: [] -}; - -sda.partitionTable = { - type: "gpt", - partitions: [sda1, sda2], - unpartitionedSize: 512 -}; - -const sdb = { - sid: "62", - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - size: 2048, - recoverableSize: 0, - systems : [], - udevIds: [], - udevPaths: ["pci-0000:00-19"] -}; - -let settings; - -beforeEach(() => { - settings = { - installationDevices: [sda, sdb], - spacePolicy: "keep", - spaceActions: [ - { device: "/dev/sda1", action: "force_delete" }, - { device: "/dev/sda2", action: "resize" } - ], - }; - - resetLocalStorage(); -}); - -describe("ProposalSpacePolicySection", () => { - it("renders the space policy picker", () => { - plainRender(); - const picker = screen.getByRole("listbox"); - within(picker).getByRole("option", { name: /delete/i }); - within(picker).getByRole("option", { name: /resize/i }); - within(picker).getByRole("option", { name: /available/i }); - within(picker).getByRole("option", { name: /custom/i }); - }); - - it("triggers the onChange callback when user changes selected space policy", async () => { - const onChangeFn = jest.fn(); - const { user } = plainRender(); - const picker = screen.getByRole("listbox"); - await user.selectOptions( - picker, - within(picker).getByRole("option", { name: /custom/i }) - ); - expect(onChangeFn).toHaveBeenCalledWith({ spacePolicy: "custom" }); - }); - - describe("when there are no installation devices", () => { - beforeEach(() => { - settings.installationDevices = []; - }); - - it("does not render the policy actions", () => { - plainRender(); - const actions = screen.queryByRole("treegrid", { name: "Actions to find space" }); - expect(actions).toBeNull(); - }); - }); - - describe("when there are installation devices", () => { - it("renders the policy actions", () => { - plainRender(); - screen.getByRole("treegrid", { name: "Actions to find space" }); - }); - }); - - describe.each([ - { id: 'delete', nameRegexp: /delete/i }, - { id: 'resize', nameRegexp: /shrink/i }, - { id: 'keep', nameRegexp: /the space not assigned/i } - ])("when space policy is '$id'", ({ id, nameRegexp }) => { - beforeEach(() => { - settings.spacePolicy = id; - }); - - it("only renders '$id' option as selected", () => { - plainRender(); - const picker = screen.getByRole("listbox"); - within(picker).getByRole("option", { name: nameRegexp, selected: true }); - expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); - }); - - it("does not allow to modify the space actions", () => { - plainRender(); - // NOTE: HTML `disabled` attribute removes the element from the a11y tree. - // That's why the test is using `hidden: true` here to look for disabled actions. - // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled - // https://testing-library.com/docs/queries/byrole/#hidden - // TODO: use a more inclusive way to disable the actions. - // https://css-tricks.com/making-disabled-buttons-more-inclusive/ - const spaceActions = screen.getAllByRole("combobox", { name: /Space action selector/, hidden: true }); - expect(spaceActions.length).toEqual(3); - }); - }); - - describe("when space policy is 'custom'", () => { - beforeEach(() => { - settings.spacePolicy = "custom"; - }); - - it("only renders 'custom' option as selected", () => { - plainRender(); - const picker = screen.getByRole("listbox"); - within(picker).getByRole("option", { name: /custom/i, selected: true }); - expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); - }); - - it("allows to modify the space actions", () => { - plainRender(); - const spaceActions = screen.getAllByRole("combobox", { name: /Space action selector/ }); - expect(spaceActions.length).toEqual(3); - }); - }); - - describe("DeviceActionColumn", () => { - it("renders the space actions selector for devices without partition table", () => { - plainRender(); - const sdaRow = screen.getByRole("row", { name: /sda/ }); - const sdaActionsSelector = within(sdaRow).queryByRole("combobox", { name: "Space action selector for /dev/sda" }); - // sda has partition table, the selector shouldn't be found - expect(sdaActionsSelector).toBeNull(); - const sdbRow = screen.getByRole("row", { name: /sdb/ }); - // sdb does not have partition table, selector should be there - within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); - }); - - it("does not renders the 'resize' option for drives", () => { - plainRender(); - const sdbRow = screen.getByRole("row", { name: /sdb/ }); - const spaceActionsSelector = within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); - const resizeOption = within(spaceActionsSelector).queryByRole("option", { name: /resize/ }); - expect(resizeOption).toBeNull(); - }); - - it("renders the 'resize' option for devices other than drives", async () => { - const { user } = plainRender(); - const sdaRow = screen.getByRole("row", { name: /sda/ }); - const sdaToggler = within(sdaRow).getByRole("button", { name: /expand/i }); - await user.click(sdaToggler); - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const spaceActionsSelector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); - within(spaceActionsSelector).getByRole("option", { name: /resize/ }); - }); - - it("renders as selected the option matching the given device space action", async () => { - const { user } = plainRender(); - const sdaRow = screen.getByRole("row", { name: /sda/ }); - const sdaToggler = within(sdaRow).getByRole("button", { name: /expand/i }); - await user.click(sdaToggler); - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const sda1SpaceActionsSelector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); - within(sda1SpaceActionsSelector).getByRole("option", { name: /delete/i, selected: true }); - const sda2Row = screen.getByRole("row", { name: /sda2/ }); - const sda2SpaceActionsSelector = within(sda2Row).getByRole("combobox", { name: "Space action selector for /dev/sda2" }); - within(sda2SpaceActionsSelector).getByRole("option", { name: /resize/i, selected: true }); - }); - - it("triggers the onChange callback when user changes space action", async () => { - const onChangeFn = jest.fn(); - const { user } = plainRender( - - ); - - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const selector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); - await user.selectOptions( - selector, - within(selector).getByRole("option", { name: /resize/i, selected: false }) - ); - expect(onChangeFn).toHaveBeenCalledWith( - expect.objectContaining({ - spaceActions: expect.arrayContaining([{ action: "resize", device: "/dev/sda1" }]) - }) - ); - }); - }); -}); diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index c7254ed6b5..a3e2f873ae 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -408,6 +408,9 @@ export default function ProposalVolumes({ <> + + {_("File systems to create in your system")} +