From d26f7de0d4895e2c85d4ecded455bdf2e3f9255d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 5 Apr 2024 17:03:14 +0100 Subject: [PATCH] web: WIP --- web/src/assets/styles/blocks.scss | 9 +- web/src/components/core/TreeTable.jsx | 29 +- .../storage/ProposalResultSection.jsx | 4 +- .../storage/ProposalSettingsSection.jsx | 8 +- .../storage/ProposalSettingsSection.test.jsx | 41 ++ .../components/storage/SpaceActionsTable.jsx | 184 +++++++++ .../components/storage/SpacePolicyDialog.jsx | 341 ++--------------- .../storage/SpacePolicyDialog.test.jsx | 351 ++++++++++++++++++ web/src/components/storage/index.js | 1 + 9 files changed, 650 insertions(+), 318 deletions(-) create mode 100644 web/src/components/storage/SpaceActionsTable.jsx create mode 100644 web/src/components/storage/SpacePolicyDialog.test.jsx diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 50b32062af..7b4bd2874e 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -526,9 +526,7 @@ table[data-type="agama/tree-table"] { } } -// FIXME: use a single selector -table.devices-table, -table.proposal-result { +table.devices-table { tr.dimmed-row { background-color: #fff; opacity: 0.8; @@ -538,8 +536,11 @@ table.proposal-result { color: var(--color-gray-dimmed); padding-block: 0; } - } +} + +table.proposal-result { + @extend .devices-table; /** * Temporary hack because the collapse/expand callback was not given to the diff --git a/web/src/components/core/TreeTable.jsx b/web/src/components/core/TreeTable.jsx index 5f7612adb8..b7f12d1ad9 100644 --- a/web/src/components/core/TreeTable.jsx +++ b/web/src/components/core/TreeTable.jsx @@ -21,7 +21,7 @@ // @ts-check -import React from "react"; +import React, { useEffect, useState } from "react"; import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table'; /** @@ -39,6 +39,7 @@ import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/rea * @typedef {object} TreeTableBaseProps * @property {TreeTableColumn[]} columns=[] * @property {object[]} items=[] + * @property {object[]} [expandedItems=[]] * @property {(any) => array} [itemChildren] * @property {(any) => string} [rowClassNames] */ @@ -58,9 +59,26 @@ export default function TreeTable({ columns = [], items = [], itemChildren = () => [], + expandedItems = [], rowClassNames = () => "", ...tableProps }) { + const [expanded, setExpanded] = useState(expandedItems); + + useEffect(() => { + setExpanded(expandedItems); + }, [expandedItems, setExpanded]); + + const isExpanded = (item) => expanded.includes(item); + + const toggle = (item) => { + if (isExpanded(item)) { + setExpanded(expanded.filter(d => d !== item)); + } else { + setExpanded([...expanded, item]); + } + }; + const renderColumns = (item, treeRow) => { return columns.map((c, cIdx) => { const props = { @@ -76,17 +94,20 @@ export default function TreeTable({ }); }; - const renderRows = (items, level) => { + const renderRows = (items, level, hidden = false) => { if (items?.length <= 0) return; return ( items.map((item, itemIdx) => { const children = itemChildren(item); + const expanded = isExpanded(item); const treeRow = { + onCollapse: () => toggle(item), props: { - isExpanded: true, + isExpanded: expanded, isDetailsExpanded: true, + isHidden: hidden, "aria-level": level, "aria-posinset": itemIdx + 1, "aria-setsize": children?.length || 0 @@ -101,7 +122,7 @@ export default function TreeTable({ return ( {renderColumns(item, treeRow)} - { renderRows(children, level + 1)} + { renderRows(children, level + 1, !expanded)} ); }) diff --git a/web/src/components/storage/ProposalResultSection.jsx b/web/src/components/storage/ProposalResultSection.jsx index 0f4f60249e..b3a74a13c7 100644 --- a/web/src/components/storage/ProposalResultSection.jsx +++ b/web/src/components/storage/ProposalResultSection.jsx @@ -180,6 +180,7 @@ const DevicesTreeTable = ({ devicesManager }) => { }; const renderMountPoint = (item) => item.sid && {item.filesystem?.mountPath}; + const devices = devicesManager.usedDevices(); return ( { { title: _("Details"), content: renderDetails, classNames: "details-column" }, { title: _("Size"), content: renderSize, classNames: "sizes-column" } ]} - items={devicesManager.usedDevices()} + items={devices} + expandedItems={devices} itemChildren={d => deviceChildren(d)} rowClassNames={(item) => { if (!item.sid) return "dimmed-row"; diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 1b6d74ad9b..3ae852af7b 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -342,7 +342,7 @@ const BootConfigField = ({ * @component * * @param {object} props - * @param {SpacePolicy} props.policy + * @param {SpacePolicy|undefined} props.policy * @param {SpaceAction[]} props.actions * @param {StorageDevice[]} props.devices * @param {boolean} props.isLoading @@ -378,7 +378,7 @@ const SpacePolicyField = ({ return sprintf(n_(policy.summaryLabels[0], policy.summaryLabels[1], devices.length), devices.length); }; - if (isLoading) { + if (isLoading || !policy) { return ; } @@ -460,7 +460,7 @@ export default function ProposalSettingsSection({ const lvm = settings.target === "newLvmVg" || settings.target === "reusedLvmVg"; const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; - const { volumes = [], installationDevices = [] } = settings; + const { volumes = [], installationDevices = [], spaceActions = [] } = settings; const bootDevice = availableDevices.find(d => d.name === settings.bootDevice); const defaultBootDevice = availableDevices.find(d => d.name === settings.defaultBootDevice); const spacePolicy = SPACE_POLICIES.find(p => p.id === settings.spacePolicy); @@ -505,7 +505,7 @@ export default function ProposalSettingsSection({ /> { }); }); }); + +describe("Space policy field", () => { + describe("if there is no space policy", () => { + beforeEach(() => { + props.settings = {}; + }); + + it("does not render the space policy field", () => { + plainRender(); + + expect(screen.queryByLabelText("Find space")).toBeNull(); + }); + }); + + describe("if there is a space policy", () => { + beforeEach(() => { + props.settings = { + spacePolicy: "delete" + }; + }); + + 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/ }); + }); + + it("allows to change the policy", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /deleting all content/ }); + + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Find space"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + }); + }); +}); diff --git a/web/src/components/storage/SpaceActionsTable.jsx b/web/src/components/storage/SpaceActionsTable.jsx new file mode 100644 index 0000000000..f78d044040 --- /dev/null +++ b/web/src/components/storage/SpaceActionsTable.jsx @@ -0,0 +1,184 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React from "react"; +import { FormSelect, FormSelectOption } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { FilesystemLabel } from "~/components/storage"; +import { deviceChildren, deviceSize } from '~/components/storage/utils'; +import { If, Tag, TreeTable } from "~/components/core"; +import { sprintf } from "sprintf-js"; + +/** + * @typedef {import ("~/client/storage").SpaceAction} SpaceAction + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * Column content. + * @component + * + * @param {object} props + * @param {StorageDevice} props.device + */ +const DeviceName = ({ device }) => { + let name = device.sid && device.name; + // NOTE: returning a fragment here to avoid a weird React complaint when using a PF/Table + + // treeRow props. + if (!name) return <>; + + if (["partition"].includes(device.type)) + name = name.split("/").pop(); + + return ( + {name} + ); +}; + +/** + * Column content. + * @component + * + * @param {object} props + * @param {StorageDevice} props.device + */ +const DeviceDetails = ({ device }) => { + if (!device.sid) return _("Unused space"); + + const renderContent = (device) => { + if (!device.partitionTable && device.systems?.length > 0) + return device.systems.join(", "); + + return device.description; + }; + + const renderPTableType = (device) => { + const type = device.partitionTable?.type; + if (type) return {type.toUpperCase()}; + }; + + return ( +
{renderContent(device)} {renderPTableType(device)}
+ ); +}; + +/** + * Column content. + * @component + * + * @param {object} props + * @param {StorageDevice} props.device + */ +const DeviceSizeDetails = ({ device }) => { + if (!device.sid || device.isDrive || device.recoverableSize === 0) return null; + + return deviceSize(device.recoverableSize); +}; + +/** + * Column content with the space action for a device. + * @component + * + * @param {object} props + * @param {StorageDevice} props.device + * @param {string} props.action - Possible values: "force_delete", "resize" or "keep". + * @param {boolean} [props.isDisabled=false] + * @param {(action: SpaceAction) => void} [props.onChange] + */ +const DeviceAction = ({ device, action, isDisabled = false, onChange }) => { + if (!device.sid || device.partitionTable) return null; + + const changeAction = (_, action) => onChange({ device: device.name, action }); + + return ( + + + {/* Resize action does not make sense for drives, so it is filtered out. */} + } + /> + + + ); +}; + +/** + * Table for selecting the space actions of the given devices. + * @component + * + * @param {object} props + * @param {StorageDevice[]} props.devices + * @param {StorageDevice[]} [props.expandedDevices=[]] - Initially expanded devices. + * @param {boolean} [props.isActionDisabled=false] - Whether the action selector is disabled. + * @param {(device: StorageDevice) => string} props.deviceAction - Gets the action for a device. + * @param {(action: SpaceAction) => void} props.onActionChange + */ +export default function SpaceActionsTable({ + devices, + expandedDevices = [], + isActionDisabled = false, + deviceAction, + onActionChange, +}) { + const columns = [ + { title: _("Device"), content: (device) => }, + { title: _("Details"), content: (device) => }, + { title: _("Size"), content: (device) => deviceSize(device.size) }, + { title: _("Shrinkable"), content: (device) => }, + { + title: _("Action"), + content: (device) => ( + + ) + } + ]; + + return ( + deviceChildren(d)} + rowClassNames={(item) => { + if (!item.sid) return "dimmed-row"; + }} + className="devices-table" + /> + ); +} diff --git a/web/src/components/storage/SpacePolicyDialog.jsx b/web/src/components/storage/SpacePolicyDialog.jsx index 8fa3b8f798..6de1a55a47 100644 --- a/web/src/components/storage/SpacePolicyDialog.jsx +++ b/web/src/components/storage/SpacePolicyDialog.jsx @@ -22,318 +22,22 @@ // @ts-check import React, { useEffect, useState } from "react"; -import { Form, FormSelect, FormSelectOption } from "@patternfly/react-core"; +import { Form } from "@patternfly/react-core"; -import { _, N_ } from "~/i18n"; -import { deviceSize, SPACE_POLICIES } from '~/components/storage/utils'; +import { _ } from "~/i18n"; +import { SPACE_POLICIES } from '~/components/storage/utils'; import { If, OptionsPicker, Popup } from "~/components/core"; import { noop } from "~/utils"; -import { sprintf } from "sprintf-js"; -import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table'; +import { SpaceActionsTable } from '~/components/storage'; /** - * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings * @typedef {import ("~/client/storage").SpaceAction} SpaceAction * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy * @typedef {import ("~/client/storage").StorageDevice} StorageDevice */ -// Names of the columns for the policy actions. -const columnNames = { - device: N_("Used device"), - content: N_("Details"), - size: N_("Size"), - details: N_("Size details"), - action: N_("Action") -}; - -/** - * Column content with the description of a device. - * @component - * - * @param {object} props - * @param {StorageDevice} props.device - */ -const DeviceDescriptionColumn = ({ device }) => { - if (device.isDrive || device.type === "lvmVg") return device.name; - - return device.name.split("/").pop(); -}; - -/** - * Column content with details about the device. - * @component - * - * @param {object} props - * @param {StorageDevice} props.device - */ -const DeviceContentColumn = ({ device }) => { - const systems = device.systems; - if (systems.length > 0) return systems.join(", "); - - return device.description; -}; - -/** - * Column content with information about the size of the device. - * @component - * - * @param {object} props - * @param {StorageDevice} props.device - */ -const DeviceSizeColumn = ({ device }) => { - return deviceSize(device.size); -}; - -/** - * Column content with details about the device. - * @component - * - * @param {object} props - * @param {StorageDevice} props.device - */ -const DeviceDetailsColumn = ({ device }) => { - const UnusedSize = () => { - if (device.filesystem) return null; - - const unused = device.partitionTable?.unpartitionedSize || 0; - // 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; - - // TRANSLATORS: %s is replaced by a disk size (e.g., 2 GiB) - return sprintf(_("Shrinkable by %s"), deviceSize(device.recoverableSize)); - }; - - return ( - } else={} /> - ); -}; - -/** - * Column content with the space action for a device. - * @component - * - * @param {object} props - * @param {StorageDevice} props.device - * @param {string} props.action - Possible values: "force_delete", "resize" or "keep". - * @param {boolean} [props.isDisabled=false] - * @param {(action: SpaceAction) => void} [props.onChange] - */ -const DeviceActionColumn = ({ device, action, isDisabled = false, onChange = noop }) => { - const changeAction = (_, action) => onChange({ device: device.name, action }); - - // For a drive device (e.g., Disk, RAID) it does not make sense to offer the resize action. - // At this moment, the Agama backend generates a resize action for drives if the policy is set to - // 'resize'. In that cases, the action is converted here to 'keep'. - const value = (device.isDrive && action === "resize") ? "keep" : action; - - return ( - - - {/* Resize action does not make sense for drives, so it is filtered out. */} - } - /> - - - ); -}; - -/** - * Row for configuring the space action of a device. - * @component - * - * @see {@link https://www.patternfly.org/components/table/#tree-table} - * - * @param {object} props - * @param {StorageDevice} props.device - * @param {SpacePolicy} props.policy - * @param {SpaceAction[]} props.actions - * @param {number} props.rowIndex - * @param {number} [props.level=1] - * @param {number} [props.setSize=0] - * @param {number} [props.posInSet=0] - * @param {boolean} [props.isExpanded=false] - * @param {boolean} [props.isHidden=false] - * @param {() => void} [props.onCollapse=noop] - * @param {(action: SpaceAction) => void} [props.onChange=noop] - */ -const DeviceRow = ({ - device, - policy, - actions, - rowIndex, - level = 1, - setSize = 0, - posInSet = 1, - isExpanded = false, - isHidden = false, - 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, - props: { - isExpanded, - isDetailsExpanded: true, - isHidden, - 'aria-level': level, - 'aria-posinset': posInSet, - 'aria-setsize': setSize - } - }; - - return ( - - {/* eslint-disable agama-i18n/string-literals */} - - - - - - - - - } - /> - - {/* eslint-enable agama-i18n/string-literals */} - - ); -}; - /** - * Table for configuring the space actions. - * @component - * - * @param {object} props - * @param {SpacePolicy} props.policy - * @param {SpaceAction[]} props.actions - * @param {StorageDevice[]} props.devices - * @param {(action: SpaceAction) => void} [props.onChange] - */ -const SpaceActionsTable = ({ policy, actions, devices, onChange = noop }) => { - const [expandedDevices, setExpandedDevices] = useState([]); - const [autoExpanded, setAutoExpanded] = useState(false); - - useEffect(() => { - const devNames = devices.map(d => d.name); - let currentExpanded = devNames.filter(d => expandedDevices.includes(d)); - - if (policy.id === "custom" && !autoExpanded) { - currentExpanded = [...devNames]; - setAutoExpanded(true); - } else if (policy.id !== "custom" && autoExpanded) { - setAutoExpanded(false); - } - - if (currentExpanded.sort().toString() !== expandedDevices.sort().toString()) { - setExpandedDevices(currentExpanded); - } - }, [autoExpanded, expandedDevices, setAutoExpanded, setExpandedDevices, policy, devices]); - - const renderRows = () => { - const rows = []; - - devices?.forEach((device, index) => { - const isExpanded = expandedDevices.includes(device.name); - const children = device.partitionTable?.partitions; - - const onCollapse = () => { - const otherExpandedDevices = expandedDevices.filter(name => name !== device.name); - const expanded = isExpanded ? otherExpandedDevices : [...otherExpandedDevices, device.name]; - setExpandedDevices(expanded); - }; - - rows.push( - - ); - - children?.forEach((child, index) => { - rows.push( - - ); - }); - }); - - return rows; - }; - - return ( - - - - - - - - - - - {renderRows()} -
{columnNames.device}{columnNames.content}{columnNames.size}{columnNames.details}{columnNames.action}
- ); -}; - -/** - * Widget to allow user picking desired policy to make space + * Widget to allow user picking desired policy to make space. * @component * * @param {object} props @@ -388,6 +92,11 @@ export default function SpacePolicyDialog({ const [policy, setPolicy] = useState(defaultPolicy); const [actions, setActions] = useState(defaultActions); const [customUsed, setCustomUsed] = useState(false); + const [expandedDevices, setExpandedDevices] = useState([]); + + useEffect(() => { + if (policy.id === "custom") setExpandedDevices(devices); + }, [devices, policy, setExpandedDevices]); // 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 @@ -403,6 +112,23 @@ export default function SpacePolicyDialog({ if (policy.id !== "custom" && !customUsed) setActions([]); }, [policy, customUsed, setActions]); + // Generates the action value according to the policy. + const deviceAction = (device) => { + let action; + + if (policy.id === "custom") { + action = actions.find(a => a.device === device.name)?.action || "keep"; + } else { + const policyAction = { delete: "force_delete", resize: "resize", keep: "keep" }; + action = policyAction[policy.id]; + } + + // For a drive device (e.g., Disk, RAID) it does not make sense to offer the resize action. + // At this moment, the Agama backend generates a resize action for drives if the policy is set + // to 'resize'. In that cases, the action is converted here to 'keep'. + return ((device.isDrive && action === "resize") ? "keep" : action); + }; + const changeActions = (spaceAction) => { const spaceActions = actions.filter(a => a.device !== spaceAction.device); if (spaceAction.action !== "keep") spaceActions.push(spaceAction); @@ -415,9 +141,13 @@ export default function SpacePolicyDialog({ onAccept({ spacePolicy: policy, spaceActions: actions }); }; + const description = _("Allocating the file systems might need to find free space \ +in the devices listed below. Choose how to do it."); + return ( 0} then={ } /> diff --git a/web/src/components/storage/SpacePolicyDialog.test.jsx b/web/src/components/storage/SpacePolicyDialog.test.jsx new file mode 100644 index 0000000000..0c513c5511 --- /dev/null +++ b/web/src/components/storage/SpacePolicyDialog.test.jsx @@ -0,0 +1,351 @@ +/* + * 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 { SPACE_POLICIES } from "~/components/storage/utils"; +import { SpacePolicyDialog } 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], + unusedSlots: [{ start: 123, size: 4455666 }], + 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"] +}; + +const deletePolicy = SPACE_POLICIES.find(p => p.id === "delete"); +const resizePolicy = SPACE_POLICIES.find(p => p.id === "resize"); +const keepPolicy = SPACE_POLICIES.find(p => p.id === "keep"); +const customPolicy = SPACE_POLICIES.find(p => p.id === "custom"); + +let props; + +const expandRow = async (user, name) => { + const row = screen.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(() => { + props = { + isOpen: true, + policy: keepPolicy, + actions: [ + { device: "/dev/sda1", action: "force_delete" }, + { device: "/dev/sda2", action: "resize" } + ], + devices: [sda, sdb], + onAccept: jest.fn() + }; + + resetLocalStorage(); +}); + +describe("SpacePolicyDialog", () => { + it("renders the space policy picker", async () => { + 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 }); + }); + + describe("when there are no installation devices", () => { + beforeEach(() => { + props.devices = []; + }); + + it("does not render the policy actions", async () => { + plainRender(); + const actionsTree = screen.queryByRole("treegrid", { name: "Actions to find space" }); + expect(actionsTree).toBeNull(); + }); + }); + + describe("when there are installation devices", () => { + beforeEach(() => { + props.devices = [sda, sdb]; + }); + + it("renders the policy actions", async () => { + plainRender(); + screen.getByRole("treegrid", { name: "Actions to find space" }); + }); + }); + + describe.each([ + { id: 'delete', nameRegexp: /delete/i }, + { id: 'resize', nameRegexp: /shrink/i }, + { id: 'keep', nameRegexp: /use available/i } + ])("when space policy is '$id'", ({ id, nameRegexp }) => { + beforeEach(() => { + props.policy = SPACE_POLICIES.find(p => p.id === id); + }); + + it("only renders '$id' option as selected", async () => { + 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", async () => { + 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(() => { + props.policy = customPolicy; + }); + + it("only renders 'custom' option as selected", async () => { + 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", async () => { + plainRender(); + const spaceActions = screen.getAllByRole("combobox", { name: /Space action selector/ }); + expect(spaceActions.length).toEqual(3); + }); + }); + + describe("Device action", () => { + beforeEach(() => { + props.policy = customPolicy; + }); + + it("renders the space actions selector for devices without partition table", async () => { + plainRender(); + // sda has partition table, the selector shouldn't be found + const sdaRow = screen.getByRole("row", { name: /sda gpt/i }); + const sdaActionsSelector = within(sdaRow).queryByRole("combobox"); + expect(sdaActionsSelector).toBeNull(); + // Unused space, the selector shouldn't be found + const unusedRow = screen.getByRole("row", { name: /unused space/i }); + const unusedActionsSelector = within(unusedRow).queryByRole("combobox"); + expect(unusedActionsSelector).toBeNull(); + // sdb does not have partition table, selector should be there + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + within(sdbRow).getByRole("combobox"); + }); + + it("does not renders the 'resize' option for drives", async () => { + plainRender(); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + const spaceActionsSelector = within(sdbRow).getByRole("combobox"); + const resizeOption = within(spaceActionsSelector).queryByRole("option", { name: /resize/ }); + expect(resizeOption).toBeNull(); + }); + + it("renders the 'resize' option for devices other than drives", async () => { + plainRender(); + const sda1Row = screen.getByRole("row", { name: /sda1/ }); + const spaceActionsSelector = within(sda1Row).getByRole("combobox"); + within(spaceActionsSelector).getByRole("option", { name: /resize/ }); + }); + + describe("when space policy is 'delete'", () => { + beforeEach(() => { + props.policy = deletePolicy; + }); + + it("renders as selected the delete option", async () => { + const { user } = plainRender(); + await expandRow(user, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /delete/i }, + { name: /sda2/, action: /delete/i } + ]); + }); + }); + + describe("when space policy is 'resize'", () => { + beforeEach(() => { + props.policy = resizePolicy; + }); + + it("renders as selected the resize option", async () => { + const { user } = plainRender(); + await expandRow(user, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /resize/i }, + { name: /sda2/, action: /resize/i } + ]); + }); + }); + + describe("when space policy is 'keep'", () => { + beforeEach(() => { + props.policy = keepPolicy; + }); + + it("renders as selected the keep option", async () => { + const { user } = plainRender(); + await expandRow(user, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /not modify/i }, + { name: /sda2/, action: /not modify/i } + ]); + }); + }); + + describe("when space policy is 'custom'", () => { + beforeEach(() => { + props.policy = customPolicy; + }); + + it("renders as selected the option matching the given device space action", async () => { + plainRender(); + await checkSpaceActions([ + { name: /sda1/, action: /delete/i }, + { name: /sda2/, action: /resize/i } + ]); + }); + }); + }); + + it("triggers the onAccept callback when user accepts the dialog", async () => { + const { user } = plainRender(); + + // Select 'custom' + const picker = screen.getByRole("listbox"); + await user.selectOptions( + picker, + within(picker).getByRole("option", { name: /custom/i }) + ); + + // Select custom actions + const sda1Row = screen.getByRole("row", { name: /sda1/ }); + const sda1Select = within(sda1Row).getByRole("combobox"); + await user.selectOptions( + sda1Select, + within(sda1Select).getByRole("option", { name: /delete/i }) + ); + const sda2Row = screen.getByRole("row", { name: /sda2/ }); + const sda2Select = within(sda2Row).getByRole("combobox"); + await user.selectOptions( + sda2Select, + within(sda2Select).getByRole("option", { name: /resize/i }) + ); + + // Accept the result + const acceptButton = screen.getByRole("button", { name: "Confirm" }); + await user.click(acceptButton); + + expect(props.onAccept).toHaveBeenCalledWith({ + spacePolicy: customPolicy, + spaceActions: expect.arrayContaining([ + { action: "resize", device: "/dev/sda2" }, + { action: "force_delete", device: "/dev/sda1" } + ]) + }); + }); +}); diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index 78220d469f..eda9195002 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -41,3 +41,4 @@ export { default as DeviceSelectionDialog } from "./DeviceSelectionDialog"; export { default as DeviceSelectorTable } from "./DeviceSelectorTable"; export { default as DevicesFormSelect } from "./DevicesFormSelect"; export { default as SpacePolicyDialog } from "./SpacePolicyDialog"; +export { default as SpaceActionsTable } from "./SpaceActionsTable";