diff --git a/web/src/components/core/ExpandableSelector.jsx b/web/src/components/core/ExpandableSelector.jsx index 7499f37f1d..f81df3f271 100644 --- a/web/src/components/core/ExpandableSelector.jsx +++ b/web/src/components/core/ExpandableSelector.jsx @@ -24,6 +24,11 @@ import React, { useState } from "react"; import { Table, Thead, Tr, Th, Tbody, Td, ExpandableRowContent, RowSelectVariant } from "@patternfly/react-table"; +/** + * @typedef {import("@patternfly/react-table").TableProps} TableProps + * @typedef {import("react").RefAttributes} HTMLTableProps + */ + /** * An object for sharing data across nested maps * @@ -93,23 +98,26 @@ const sanitizeSelection = (selection, allowMultiple) => { }; /** - * Build a expandable table with selectable items + * Build a expandable table with selectable items. * @component * * @note It only accepts one nesting level. * - * @param {object} props - * @param {ExpandableSelectorColumn[]} props.columns - Collection of objects defining columns. - * @param {boolean} [props.isMultiple=false] - Whether multiple selection is allowed. - * @param {object[]} props.items - Collection of items to be rendered. - * @param {string} [props.itemIdKey="id"] - The key for retrieving the item id. - * @param {(item: object) => Array} [props.itemChildren=() => []] - Lookup method to retrieve children from given item. - * @param {(item: object) => boolean} [props.itemSelectable=() => true] - Whether an item will be selectable or not. - * @param {(item: object) => (string|undefined)} [props.itemClassNames=() => ""] - Callback that allows adding additional CSS class names to item row. - * @param {object[]} [props.itemsSelected=[]] - Collection of selected items. - * @param {string[]} [props.initialExpandedKeys=[]] - Ids of initially expanded items. - * @param {(selection: Array) => void} [props.onSelectionChange=noop] - Callback to be triggered when selection changes. - * @param {object} [props.tableProps] - Props for {@link https://www.patternfly.org/components/table/#table PF/Table}. + * @typedef {object} ExpandableSelectorBaseProps + * @property {ExpandableSelectorColumn[]} [columns=[]] - Collection of objects defining columns. + * @property {boolean} [isMultiple=false] - Whether multiple selection is allowed. + * @property {object[]} [items=[]] - Collection of items to be rendered. + * @property {string} [itemIdKey="id"] - The key for retrieving the item id. + * @property {(item: object) => Array} [itemChildren=() => []] - Lookup method to retrieve children from given item. + * @property {(item: object) => boolean} [itemSelectable=() => true] - Whether an item will be selectable or not. + * @property {(item: object) => (string|undefined)} [itemClassNames=() => ""] - Callback that allows adding additional CSS class names to item row. + * @property {object[]} [itemsSelected=[]] - Collection of selected items. + * @property {string[]} [initialExpandedKeys=[]] - Ids of initially expanded items. + * @property {(selection: Array) => void} [onSelectionChange=noop] - Callback to be triggered when selection changes. + * + * @typedef {ExpandableSelectorBaseProps & TableProps & HTMLTableProps} ExpandableSelectorProps + * + * @param {ExpandableSelectorProps} props */ export default function ExpandableSelector({ columns = [], @@ -126,7 +134,14 @@ export default function ExpandableSelector({ }) { const [expandedItemsKeys, setExpandedItemsKeys] = useState(initialExpandedKeys); const selection = sanitizeSelection(itemsSelected, isMultiple); - const isItemSelected = (item) => selection.includes(item); + const isItemSelected = (item) => { + const selected = selection.find((selectionItem) => { + return Object.hasOwn(selectionItem, itemIdKey) && + selectionItem[itemIdKey] === item[itemIdKey]; + }); + + return selected !== undefined || selection.includes(item); + }; const isItemExpanded = (key) => expandedItemsKeys.includes(key); const toggleExpanded = (key) => { if (isItemExpanded(key)) { diff --git a/web/src/components/core/TreeTable.jsx b/web/src/components/core/TreeTable.jsx index b7f12d1ad9..2f258a13f9 100644 --- a/web/src/components/core/TreeTable.jsx +++ b/web/src/components/core/TreeTable.jsx @@ -30,8 +30,8 @@ import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/rea /** * @typedef {object} TreeTableColumn - * @property {string} title - * @property {(any) => React.ReactNode} content + * @property {string} name + * @property {(object) => React.ReactNode} value * @property {string} [classNames] */ @@ -82,14 +82,14 @@ export default function TreeTable({ const renderColumns = (item, treeRow) => { return columns.map((c, cIdx) => { const props = { - dataLabel: c.title, + dataLabel: c.name, className: c.classNames }; if (cIdx === 0) props.treeRow = treeRow; return ( - {c.content(item)} + {c.value(item)} ); }); }; @@ -138,7 +138,7 @@ export default function TreeTable({ > - { columns.map((c, i) => {c.title}) } + { columns.map((c, i) => {c.name}) } diff --git a/web/src/components/storage/DeviceSelectionDialog.jsx b/web/src/components/storage/DeviceSelectionDialog.jsx index 5efdeeb019..26c66e40fa 100644 --- a/web/src/components/storage/DeviceSelectionDialog.jsx +++ b/web/src/components/storage/DeviceSelectionDialog.jsx @@ -28,7 +28,7 @@ import { _ } from "~/i18n"; import { deviceChildren } from "~/components/storage/utils"; import { ControlledPanels as Panels, Popup } from "~/components/core"; import { DeviceSelectorTable } from "~/components/storage"; -import { noop } from "~/utils"; +import { compact, noop } from "~/utils"; /** * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget @@ -46,20 +46,21 @@ const OPTIONS_NAME = "selection-mode"; * Renders a dialog that allows the user to select a target device for installation. * @component * - * @param {object} props - * @param {ProposalTarget} props.target - * @param {StorageDevice|undefined} props.targetDevice - * @param {StorageDevice[]} props.targetPVDevices - * @param {StorageDevice[]} props.devices - The actions to perform in the system. - * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. - * @param {() => void} [props.onCancel=noop] - * @param {(target: Target) => void} [props.onAccept=noop] + * @typedef {object} DeviceSelectionDialogProps + * @property {ProposalTarget} target + * @property {StorageDevice|undefined} targetDevice + * @property {StorageDevice[]} targetPVDevices + * @property {StorageDevice[]} devices - The actions to perform in the system. + * @property {boolean} [isOpen=false] - Whether the dialog is visible or not. + * @property {() => void} [onCancel=noop] + * @property {(target: TargetConfig) => void} [onAccept=noop] * - * @typedef {object} Target + * @typedef {object} TargetConfig * @property {string} target * @property {StorageDevice|undefined} targetDevice * @property {StorageDevice[]} targetPVDevices - + * + * @param {DeviceSelectionDialogProps} props */ export default function DeviceSelectionDialog({ target: defaultTarget, @@ -149,7 +150,7 @@ devices.").split(/[[\]]/); { @@ -124,6 +136,7 @@ describe("DeviceSelectionDialog", () => { props = { isOpen: true, target: "DISK", + targetDevice: undefined, targetPVDevices: [], devices: [sda, sdb, sdc], onCancel: jest.fn(), diff --git a/web/src/components/storage/DeviceSelectorTable.jsx b/web/src/components/storage/DeviceSelectorTable.jsx index 0653019f70..6874a181e4 100644 --- a/web/src/components/storage/DeviceSelectorTable.jsx +++ b/web/src/components/storage/DeviceSelectorTable.jsx @@ -19,33 +19,192 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; +import { sprintf } from "sprintf-js"; + import { _ } from "~/i18n"; -import { deviceSize } from '~/components/storage/utils'; -import { DeviceExtendedInfo, DeviceContentInfo } from "~/components/storage"; -import { ExpandableSelector } from "~/components/core"; +import { deviceBaseName } from "~/components/storage/utils"; +import { + DeviceName, DeviceDetails, DeviceSize, FilesystemLabel, toStorageDevice +} from "~/components/storage/device-utils"; +import { ExpandableSelector, If } from "~/components/core"; +import { Icon } from "~/components/layout"; /** - * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings + * @typedef {import("../core/ExpandableSelector").ExpandableSelectorColumn} ExpandableSelectorColumn + * @typedef {import("../core/ExpandableSelector").ExpandableSelectorProps} ExpandableSelectorProps + * @typedef {import("~/client/storage").PartitionSlot} PartitionSlot * @typedef {import ("~/client/storage").StorageDevice} StorageDevice */ -const DeviceInfo = ({ device }) => { - if (!device.sid) return _("Unused space"); +/** + * @component + * + * @param {object} props + * @param {PartitionSlot|StorageDevice} props.item + */ +const DeviceInfo = ({ item }) => { + const device = toStorageDevice(item); + if (!device) return null; + + const DeviceType = () => { + let type; + + switch (device.type) { + case "multipath": { + // TRANSLATORS: multipath device type + type = _("Multipath"); + break; + } + case "dasd": { + // TRANSLATORS: %s is replaced by the device bus ID + type = sprintf(_("DASD %s"), device.busId); + break; + } + case "md": { + // TRANSLATORS: software RAID device, %s is replaced by the RAID level, e.g. RAID-1 + type = sprintf(_("Software %s"), device.level.toUpperCase()); + break; + } + case "disk": { + if (device.sdCard) { + type = _("SD Card"); + } else { + const technology = device.transport || device.bus; + type = technology + // TRANSLATORS: %s is substituted by the type of disk like "iSCSI" or "SATA" + ? sprintf(_("%s disk"), technology) + : _("Disk"); + } + } + } + + return {type}} />; + }; + + const DeviceModel = () => { + if (!device.model || device.model === "") return null; + + return
{device.model}
; + }; + + const MDInfo = () => { + if (device.type !== "md" || !device.devices) return null; + + const members = device.devices.map(deviceBaseName); + + // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array + return
{sprintf(_("Members: %s"), members.sort().join(", "))}
; + }; + + const RAIDInfo = () => { + if (device.type !== "raid") return null; + + const devices = device.devices.map(deviceBaseName); + + // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array + return
{sprintf(_("Devices: %s"), devices.sort().join(", "))}
; + }; + + const MultipathInfo = () => { + if (device.type !== "multipath") return null; + + const wires = device.wires.map(deviceBaseName); + + // TRANSLATORS: multipath details, %s is replaced by list of connections used by the device + return
{sprintf(_("Wires: %s"), wires.sort().join(", "))}
; + }; + + return ( +
+ + + + + + +
+ ); +}; + +/** + * @component + * + * @param {object} props + * @param {PartitionSlot|StorageDevice} props.item + */ +const DeviceExtendedDetails = ({ item }) => { + const device = toStorageDevice(item); + + if (!device || ["partition", "lvmLv"].includes(device.type)) + return ; + + // TODO: there is a lot of room for improvement here, but first we would need + // device.description (comes from YaST) to be way more granular + const Description = () => { + if (device.partitionTable) { + const type = device.partitionTable.type.toUpperCase(); + const numPartitions = device.partitionTable.partitions.length; + + // TRANSLATORS: disk partition info, %s is replaced by partition table + // type (MS-DOS or GPT), %d is the number of the partitions + return sprintf(_("%s with %d partitions"), type, numPartitions); + } + + if (!!device.model && device.model === device.description) { + // TRANSLATORS: status message, no existing content was found on the disk, + // i.e. the disk is completely empty + return _("No content found"); + } + + return
{device.description}
; + }; - return ; + const Systems = () => { + if (!device.systems || device.systems.length === 0) return null; + + const System = ({ system }) => { + const logo = /windows/i.test(system) ? "windows_logo" : "linux_logo"; + + return <> {system}; + }; + + return device.systems.map((s, i) => ); + }; + + return ( +
+ + +
+ ); }; -const deviceColumns = [ - { name: _("Device"), value: (device) => }, - { name: _("Content"), value: (device) => }, - { name: _("Size"), value: (device) => deviceSize(device.size), classNames: "sizes-column" } +/** @type {ExpandableSelectorColumn[]} */ +const columns = [ + { name: _("Device"), value: (item) => }, + { name: _("Details"), value: (item) => }, + { name: _("Size"), value: (item) => , classNames: "sizes-column" } ]; -export default function DeviceSelectorTable({ devices, selected, ...props }) { +/** + * Table for selecting the installation device. + * @component + * + * @typedef {object} DeviceSelectorTableBaseProps + * @property {StorageDevice[]} devices + * @property {StorageDevice[]} selectedDevices + * + * @typedef {DeviceSelectorTableBaseProps & ExpandableSelectorProps} DeviceSelectorTableProps + * + * @param {DeviceSelectorTableProps} props + */ +export default function DeviceSelectorTable({ devices, selectedDevices, ...props }) { return ( { @@ -53,7 +212,7 @@ export default function DeviceSelectorTable({ devices, selected, ...props }) { return "dimmed-row"; } }} - itemsSelected={selected} + itemsSelected={selectedDevices} className="devices-table" {...props} /> diff --git a/web/src/components/storage/DevicesManager.js b/web/src/components/storage/DevicesManager.js index 5f70318564..78ada2d882 100644 --- a/web/src/components/storage/DevicesManager.js +++ b/web/src/components/storage/DevicesManager.js @@ -33,6 +33,8 @@ import { compact, uniq } from "~/utils"; */ export default class DevicesManager { /** + * @constructor + * * @param {StorageDevice[]} system - Devices representing the current state of the system. * @param {StorageDevice[]} staging - Devices representing the target state of the system. * @param {Action[]} actions - Actions to perform from system to staging. @@ -45,6 +47,7 @@ export default class DevicesManager { /** * System device with the given SID. + * @method * * @param {Number} sid * @returns {StorageDevice|undefined} @@ -55,6 +58,7 @@ export default class DevicesManager { /** * Staging device with the given SID. + * @method * * @param {Number} sid * @returns {StorageDevice|undefined} @@ -65,6 +69,7 @@ export default class DevicesManager { /** * Whether the given device exists in system. + * @method * * @param {StorageDevice} device * @returns {Boolean} @@ -75,6 +80,7 @@ export default class DevicesManager { /** * Whether the given device exists in staging. + * @method * * @param {StorageDevice} device * @returns {Boolean} @@ -85,6 +91,7 @@ export default class DevicesManager { /** * Whether the given device is going to be formatted. + * @method * * @param {StorageDevice} device * @returns {Boolean} @@ -100,6 +107,7 @@ export default class DevicesManager { /** * Whether the given device is going to be shrunk. + * @method * * @param {StorageDevice} device * @returns {Boolean} @@ -110,6 +118,7 @@ export default class DevicesManager { /** * Amount of bytes the given device is going to be shrunk. + * @method * * @param {StorageDevice} device * @returns {Number} @@ -126,6 +135,7 @@ export default class DevicesManager { /** * Disk devices and LVM volume groups used for the installation. + * @method * * @note The used devices are extracted from the actions. * @@ -147,6 +157,7 @@ export default class DevicesManager { /** * Devices deleted. + * @method * * @note The devices are extracted from the actions. * @@ -158,6 +169,7 @@ export default class DevicesManager { /** * Systems deleted. + * @method * * @returns {string[]} */ diff --git a/web/src/components/storage/ProposalResultSection.jsx b/web/src/components/storage/ProposalResultSection.jsx index 741a5c6c4a..1103892dc4 100644 --- a/web/src/components/storage/ProposalResultSection.jsx +++ b/web/src/components/storage/ProposalResultSection.jsx @@ -25,10 +25,10 @@ import React, { useState } from "react"; import { Button, Skeleton } from "@patternfly/react-core"; import { sprintf } from "sprintf-js"; import { _, n_ } from "~/i18n"; -import { deviceChildren, deviceSize } from "~/components/storage/utils"; import DevicesManager from "~/components/storage/DevicesManager"; -import { If, Section, Reminder, Tag, TreeTable } from "~/components/core"; -import { ProposalActionsDialog, FilesystemLabel } from "~/components/storage"; +import { If, Section, Reminder } from "~/components/core"; +import { ProposalActionsDialog } from "~/components/storage"; +import ProposalResultTable from "~/components/storage/ProposalResultTable"; /** * @typedef {import ("~/client/storage").Action} Action @@ -98,109 +98,6 @@ const ActionsInfo = ({ actions }) => { ); }; -/** - * Renders a TreeTable rendering the devices proposal result. - * @component - * - * @param {object} props - * @param {DevicesManager} props.devicesManager - */ -const DevicesTreeTable = ({ devicesManager }) => { - const renderDeviceName = (item) => { - let name = item.sid && item.name; - // NOTE: returning a fragment here to avoid a weird React complaint when using a PF/Table + - // treeRow props. - if (!name) return <>; - - if (["partition", "lvmLv"].includes(item.type)) - name = name.split("/").pop(); - - return ( -
- {name} -
- ); - }; - - const renderNewLabel = (item) => { - if (!item.sid) return; - - // FIXME New PVs over a disk is not detected as new. - if (!devicesManager.existInSystem(item) || devicesManager.hasNewFilesystem(item)) - return {_("New")}; - }; - - const renderContent = (item) => { - if (!item.sid) - return _("Unused space"); - if (!item.partitionTable && item.systems?.length > 0) - return item.systems.join(", "); - - return item.description; - }; - - const renderPTableType = (item) => { - // TODO: Create a map for partition table types. - const type = item.partitionTable?.type; - if (type) return {type.toUpperCase()}; - }; - - const renderDetails = (item) => { - return ( - <> -
{renderNewLabel(item)}
-
{renderContent(item)} {renderPTableType(item)}
- - ); - }; - - const renderResizedLabel = (item) => { - if (!item.sid || !devicesManager.isShrunk(item)) return; - - const sizeBefore = devicesManager.systemDevice(item.sid).size; - - return ( - - { - // TRANSLATORS: Label to indicate the device size before resizing, where %s is replaced by - // the original size (e.g., 3.00 GiB). - sprintf(_("Before %s"), deviceSize(sizeBefore)) - } - - ); - }; - - const renderSize = (item) => { - return ( -
- {renderResizedLabel(item)} - {deviceSize(item.size)} -
- ); - }; - - const renderMountPoint = (item) => item.sid && {item.filesystem?.mountPath}; - const devices = devicesManager.usedDevices(); - - return ( - deviceChildren(d)} - rowClassNames={(item) => { - if (!item.sid) return "dimmed-row"; - }} - className="proposal-result" - /> - ); -}; - /** * @todo Create a component for rendering a customized skeleton */ @@ -236,7 +133,7 @@ const SectionContent = ({ system, staging, actions, errors }) => { systems={devicesManager.deletedSystems()} /> - + ); }; @@ -245,12 +142,14 @@ const SectionContent = ({ system, staging, actions, errors }) => { * Section holding the proposal result and actions to perform in the system * @component * - * @param {object} props - * @param {StorageDevice[]} [props.system=[]] - * @param {StorageDevice[]} [props.staging=[]] - * @param {Action[]} [props.actions=[]] - * @param {ValidationError[]} [props.errors=[]] - Validation errors - * @param {boolean} [props.isLoading=false] - Whether the section content should be rendered as loading + * @typedef {object} ProposalResultSectionProps + * @property {StorageDevice[]} [system=[]] + * @property {StorageDevice[]} [staging=[]] + * @property {Action[]} [actions=[]] + * @property {ValidationError[]} [errors=[]] - Validation errors + * @property {boolean} [isLoading=false] - Whether the section content should be rendered as loading + * + * @param {ProposalResultSectionProps} props */ export default function ProposalResultSection({ system = [], diff --git a/web/src/components/storage/ProposalResultSection.test.jsx b/web/src/components/storage/ProposalResultSection.test.jsx index 95f098f10e..37b1b338c7 100644 --- a/web/src/components/storage/ProposalResultSection.test.jsx +++ b/web/src/components/storage/ProposalResultSection.test.jsx @@ -19,14 +19,21 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { ProposalResultSection } from "~/components/storage"; import { devices, actions } from "./test-data/full-result-example"; +/** + * @typedef {import("./ProposalResultSection").ProposalResultSectionProps} ProposalResultSectionProps + */ + const errorMessage = "Something went wrong, proposal not possible"; const errors = [{ severity: 0, message: errorMessage }]; +/** @type {ProposalResultSectionProps} */ const defaultProps = { system: devices.system, staging: devices.staging, actions }; describe("ProposalResultSection", () => { @@ -74,7 +81,7 @@ describe("ProposalResultSection", () => { // affected systems are rendered in the warning summary const props = { ...defaultProps, - actions: [{ device: 79, delete: true }] + actions: [{ device: 79, subvol: false, delete: true, text: "" }] }; plainRender(); diff --git a/web/src/components/storage/ProposalResultTable.jsx b/web/src/components/storage/ProposalResultTable.jsx new file mode 100644 index 0000000000..e581092bf5 --- /dev/null +++ b/web/src/components/storage/ProposalResultTable.jsx @@ -0,0 +1,164 @@ +/* + * 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 { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; +import { + DeviceName, DeviceDetails, DeviceSize, toStorageDevice +} from "~/components/storage/device-utils"; +import { deviceChildren, deviceSize } from "~/components/storage/utils"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import DevicesManager from "~/components/storage/DevicesManager"; +import { If, Tag, TreeTable } from "~/components/core"; + +/** + * @typedef {import("~/client/storage").PartitionSlot} PartitionSlot + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import("../core/TreeTable").TreeTableColumn} TreeTableColumn + * @typedef {StorageDevice | PartitionSlot} TableItem + */ + +/** + * @component + * @param {object} props + * @param {TableItem} props.item + */ +const MountPoint = ({ item }) => { + const device = toStorageDevice(item); + + if (!(device && device.filesystem?.mountPath)) return null; + + return {device.filesystem.mountPath}; +}; + +/** + * @component + * @param {object} props + * @param {TableItem} props.item + * @param {DevicesManager} props.devicesManager + */ +const ExtendedDeviceDetails = ({ item, devicesManager }) => { + const isNew = () => { + const device = toStorageDevice(item); + if (!device) return false; + + // FIXME New PVs over a disk is not detected as new. + return !devicesManager.existInSystem(device) || devicesManager.hasNewFilesystem(device); + }; + + return ( + <> +
+ {_("New")}} /> +
+ + + ); +}; + +/** + * @component + * @param {object} props + * @param {TableItem} props.item + * @param {DevicesManager} props.devicesManager + */ +const ExtendedDeviceSize = ({ item, devicesManager }) => { + const device = toStorageDevice(item); + const isResized = device && devicesManager.isShrunk(device); + const sizeBefore = isResized ? devicesManager.systemDevice(device.sid).size : item.size; + + return ( +
+ + { + // TRANSLATORS: Label to indicate the device size before resizing, where %s is + // replaced by the original size (e.g., 3.00 GiB). + sprintf(_("Before %s"), deviceSize(sizeBefore)) + } + + } + /> + +
+ ); +}; + +/** @type {(devicesManager: DevicesManager) => TreeTableColumn[] } */ +const columns = (devicesManager) => { + /** @type {() => (item: TableItem) => React.ReactNode} */ + const deviceRender = () => { + return (item) => ; + }; + + /** @type {() => (item: TableItem) => React.ReactNode} */ + const mountPointRender = () => { + return (item) => ; + }; + + /** @type {() => (item: TableItem) => React.ReactNode} */ + const detailsRender = () => { + return (item) => ; + }; + + /** @type {() => (item: TableItem) => React.ReactNode} */ + const sizeRender = () => { + return (item) => ; + }; + + return [ + { name: _("Device"), value: deviceRender() }, + { name: _("Mount Point"), value: mountPointRender() }, + { name: _("Details"), value: detailsRender(), classNames: "details-column" }, + { name: _("Size"), value: sizeRender(), classNames: "sizes-column" } + ]; +}; + +/** + * Renders a TreeTable rendering the devices proposal result. + * @component + * + * @typedef {object} ProposalResultTableProps + * @property {DevicesManager} devicesManager + * + * @param {ProposalResultTableProps} props + */ +export default function ProposalResultTable({ devicesManager }) { + const devices = devicesManager.usedDevices(); + + return ( + { + if (!item.sid) return "dimmed-row"; + }} + className="proposal-result" + /> + ); +} diff --git a/web/src/components/storage/SpaceActionsTable.jsx b/web/src/components/storage/SpaceActionsTable.jsx index ce7f615fa2..fb3741a535 100644 --- a/web/src/components/storage/SpaceActionsTable.jsx +++ b/web/src/components/storage/SpaceActionsTable.jsx @@ -23,75 +23,31 @@ import React from "react"; import { FormSelect, FormSelectOption } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { FilesystemLabel } from "~/components/storage"; import { deviceChildren, deviceSize } from '~/components/storage/utils'; -import { Tag, TreeTable } from "~/components/core"; -import { sprintf } from "sprintf-js"; +import { + DeviceName, DeviceDetails, DeviceSize, toStorageDevice +} from "~/components/storage/device-utils"; +import { TreeTable } from "~/components/core"; /** + * @typedef {import("~/client/storage").PartitionSlot} PartitionSlot * @typedef {import ("~/client/storage").SpaceAction} SpaceAction * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import("../core/TreeTable").TreeTableColumn} TreeTableColumn */ /** - * 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 + * @param {PartitionSlot|StorageDevice} props.item */ -const DeviceSizeDetails = ({ device }) => { - if (!device.sid || device.isDrive || device.recoverableSize === 0) return null; +const DeviceSizeDetails = ({ item }) => { + const device = toStorageDevice(item); + if (!device || device.isDrive || device.recoverableSize === 0) return null; return deviceSize(device.recoverableSize); }; @@ -131,13 +87,14 @@ const DeviceActionForm = ({ device, action, isDisabled = false, onChange }) => { * @component * * @param {object} props - * @param {StorageDevice} props.device + * @param {PartitionSlot|StorageDevice} props.item * @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) return null; +const DeviceAction = ({ item, action, isDisabled = false, onChange }) => { + const device = toStorageDevice(item); + if (!device) return null; if (device.type === "partition") { return ( @@ -163,12 +120,14 @@ const DeviceAction = ({ device, action, isDisabled = false, onChange }) => { * 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 + * @typedef {object} SpaceActionsTableProps + * @property {StorageDevice[]} devices + * @property {StorageDevice[]} [expandedDevices=[]] - Initially expanded devices. + * @property {boolean} [isActionDisabled=false] - Whether the action selector is disabled. + * @property {(item: PartitionSlot|StorageDevice) => string} deviceAction - Gets the action for a device. + * @property {(action: SpaceAction) => void} onActionChange + * + * @param {SpaceActionsTableProps} props */ export default function SpaceActionsTable({ devices, @@ -177,17 +136,18 @@ export default function SpaceActionsTable({ deviceAction, onActionChange, }) { + /** @type {TreeTableColumn[]} */ const columns = [ - { title: _("Device"), content: (device) => }, - { title: _("Details"), content: (device) => }, - { title: _("Size"), content: (device) => deviceSize(device.size) }, - { title: _("Shrinkable"), content: (device) => }, + { name: _("Device"), value: (item) => }, + { name: _("Details"), value: (item) => }, + { name: _("Size"), value: (item) => }, + { name: _("Shrinkable"), value: (item) => }, { - title: _("Action"), - content: (device) => ( + name: _("Action"), + value: (item) => ( @@ -201,7 +161,7 @@ export default function SpaceActionsTable({ items={devices} aria-label={_("Actions to find space")} expandedItems={expandedDevices} - itemChildren={d => deviceChildren(d)} + itemChildren={deviceChildren} rowClassNames={(item) => { if (!item.sid) return "dimmed-row"; }} diff --git a/web/src/components/storage/SpacePolicyDialog.jsx b/web/src/components/storage/SpacePolicyDialog.jsx index e0f5162d04..74bc708b55 100644 --- a/web/src/components/storage/SpacePolicyDialog.jsx +++ b/web/src/components/storage/SpacePolicyDialog.jsx @@ -68,17 +68,19 @@ const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { * Renders a dialog that allows the user to select the space policy and actions. * @component * - * @param {object} props - * @param {SpacePolicy} props.policy - * @param {SpaceAction[]} props.actions - * @param {StorageDevice[]} props.devices - * @param {boolean} [props.isOpen=false] - * @param {() => void} [props.onCancel=noop] - * @param {(spaceConfig: SpaceConfig) => void} [props.onAccept=noop] + * @typedef {object} SpacePolicyDialogProps + * @property {SpacePolicy} policy + * @property {SpaceAction[]} actions + * @property {StorageDevice[]} devices + * @property {boolean} [isOpen=false] + * @property {() => void} [onCancel=noop] + * @property {(spaceConfig: SpaceConfig) => void} [onAccept=noop] * * @typedef {object} SpaceConfig * @property {SpacePolicy} spacePolicy * @property {SpaceAction[]} spaceActions + * + * @param {SpacePolicyDialogProps} props */ export default function SpacePolicyDialog({ policy: defaultPolicy, diff --git a/web/src/components/storage/SpacePolicyDialog.test.jsx b/web/src/components/storage/SpacePolicyDialog.test.jsx index a28ed8ac19..c9e44f0161 100644 --- a/web/src/components/storage/SpacePolicyDialog.test.jsx +++ b/web/src/components/storage/SpacePolicyDialog.test.jsx @@ -19,12 +19,20 @@ * find current contact information at www.suse.com. */ +// @ts-check + 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"; +/** + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import("./SpacePolicyDialog").SpacePolicyDialogProps} SpacePolicyDialogProps + */ + +/** @type {StorageDevice} */ const sda = { sid: 59, isDrive: true, @@ -39,6 +47,7 @@ const sda = { sdCard: true, active: true, name: "/dev/sda", + description: "", size: 1024, recoverableSize: 0, systems : [], @@ -46,12 +55,14 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; +/** @type {StorageDevice} */ const sda1 = { sid: 60, isDrive: false, type: "partition", active: true, name: "/dev/sda1", + description: "", size: 512, recoverableSize: 128, systems : [], @@ -59,12 +70,14 @@ const sda1 = { udevPaths: [] }; +/** @type {StorageDevice} */ const sda2 = { sid: 61, isDrive: false, type: "partition", active: true, name: "/dev/sda2", + description: "", size: 512, recoverableSize: 0, systems : [], @@ -79,6 +92,7 @@ sda.partitionTable = { unpartitionedSize: 512 }; +/** @type {StorageDevice} */ const sdb = { sid: 62, isDrive: true, @@ -93,6 +107,7 @@ const sdb = { sdCard: false, active: true, name: "/dev/sdb", + description: "", size: 2048, recoverableSize: 0, systems : [], @@ -105,6 +120,7 @@ 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"); +/** @type {SpacePolicyDialogProps} */ let props; const expandRow = async (user, name) => { diff --git a/web/src/components/storage/VolumeLocationDialog.jsx b/web/src/components/storage/VolumeLocationDialog.jsx index 9261919d01..552c8c300f 100644 --- a/web/src/components/storage/VolumeLocationDialog.jsx +++ b/web/src/components/storage/VolumeLocationDialog.jsx @@ -22,11 +22,12 @@ // @ts-check import React, { useState } from "react"; -import { Checkbox, Form } from "@patternfly/react-core"; +import { Radio, Form, FormGroup } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { DevicesFormSelect } from "~/components/storage"; +import { compact } from "~/utils"; +import { DeviceSelectorTable } from "~/components/storage"; import { Popup } from "~/components/core"; -import { deviceLabel } from "~/components/storage/utils"; +import { deviceLabel, deviceChildren } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; /** @@ -107,6 +108,7 @@ export default function VolumeLocationDialog({ const selectAutoOption = () => setLocationOption("auto"); const selectDeviceOption = () => setLocationOption("device"); const toggleDedicatedVG = (_, value) => setIsDedicatedVG(value); + const selectTargetDevice = (devices) => setTargetDevice(devices[0]); const isLocationAuto = locationOption === "auto"; const isLocationDevice = locationOption === "device"; @@ -144,9 +146,27 @@ disk (%s)."), deviceLabel(defaultTargetDevice)); return _("The file system will be allocated as a logical volume at the system LVM."); }; + /** @type {(device: StorageDevice) => boolean} */ + const isDeviceSelectable = (device) => { + return device.isDrive || ["md", "partition", "lvmLv"].includes(device.type); + }; + + const locationDevices = devices.filter(d => d.isDrive || ["md", "lvmVg"].includes(d.type)); + + const expandedKeys = () => { + const sids = (device) => { + const children = device.partitionTable?.partitions || device.logicalVolumes || []; + return children.map(d => d.sid); + }; + + const device = locationDevices.find(d => sids(d).includes(targetDevice?.sid)); + return compact([device?.sid]); + }; + return ( @@ -165,30 +185,58 @@ disk (%s)."), deviceLabel(defaultTargetDevice));
- {_("Select a disk")} + {_("Select a location")}
-
+ {/*
{_("The file system will be allocated as a new partition at the selected disk.")} -
- */} + {/* */} + - + + + isChecked={isDedicatedVG} + onChange={toggleDedicatedVG} + isDisabled={!isLocationDevice} + /> + + + +
diff --git a/web/src/components/storage/device-utils.jsx b/web/src/components/storage/device-utils.jsx index c902fe89a9..0c783b4b39 100644 --- a/web/src/components/storage/device-utils.jsx +++ b/web/src/components/storage/device-utils.jsx @@ -22,24 +22,40 @@ // @ts-check import React from "react"; -import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { Icon } from "~/components/layout"; -import { If, Tag } from "~/components/core"; -import { deviceBaseName } from "~/components/storage/utils"; +import { Tag } from "~/components/core"; +import { deviceBaseName, deviceSize } from "~/components/storage/utils"; /** + * @typedef {import ("~/client/storage").PartitionSlot} PartitionSlot * @typedef {import ("~/client/storage").StorageDevice} StorageDevice */ +/** + * Conversion to StorageDevice. + * @function + * + * @param {PartitionSlot|StorageDevice} item + * @returns {StorageDevice|undefined} + */ +const toStorageDevice = (item) => { + const { sid } = /** @type {object} */ (item); + if (!sid) return undefined; + + return /** @type {StorageDevice} */ (item); +}; + /** * @component * * @param {object} props - * @param {StorageDevice} props.device + * @param {PartitionSlot|StorageDevice} props.item */ -const FilesystemLabel = ({ device }) => { +const FilesystemLabel = ({ item }) => { + const device = toStorageDevice(item); + if (!device) return null; + const label = device.filesystem?.label; if (label) return {label}; }; @@ -48,92 +64,41 @@ const FilesystemLabel = ({ device }) => { * @component * * @param {object} props - * @param {StorageDevice} props.device + * @param {PartitionSlot|StorageDevice} props.item */ -const DeviceExtendedInfo = ({ device }) => { - const DeviceName = () => { - if (device.name === undefined) return null; +const DeviceName = ({ item }) => { + const device = toStorageDevice(item); + if (!device) return null; - return
{device.name}
; - }; - - const DeviceType = () => { - let type; - - switch (device.type) { - case "multipath": { - // TRANSLATORS: multipath device type - type = _("Multipath"); - break; - } - case "dasd": { - // TRANSLATORS: %s is replaced by the device bus ID - type = sprintf(_("DASD %s"), device.busId); - break; - } - case "md": { - // TRANSLATORS: software RAID device, %s is replaced by the RAID level, e.g. RAID-1 - type = sprintf(_("Software %s"), device.level.toUpperCase()); - break; - } - case "disk": { - if (device.sdCard) { - type = _("SD Card"); - } else { - const technology = device.transport || device.bus; - type = technology - // TRANSLATORS: %s is substituted by the type of disk like "iSCSI" or "SATA" - ? sprintf(_("%s disk"), technology) - : _("Disk"); - } - } - } - - return {type}} />; - }; + if (["partition", "lvmLv"].includes(device.type)) return deviceBaseName(device); - const DeviceModel = () => { - if (!device.model || device.model === "") return null; - - return
{device.model}
; - }; - - const MDInfo = () => { - if (device.type !== "md" || !device.devices) return null; - - const members = device.devices.map(deviceBaseName); - - // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array - return
{sprintf(_("Members: %s"), members.sort().join(", "))}
; - }; + return device.name; +}; - const RAIDInfo = () => { - if (device.type !== "raid") return null; +/** + * @component + * + * @param {object} props + * @param {PartitionSlot|StorageDevice} props.item + */ +const DeviceDetails = ({ item }) => { + const device = toStorageDevice(item); + if (!device) return _("Unused space"); - const devices = device.devices.map(deviceBaseName); + const renderContent = (device) => { + if (!device.partitionTable && device.systems?.length > 0) + return device.systems.join(", "); - // TRANSLATORS: RAID details, %s is replaced by list of devices used by the array - return
{sprintf(_("Devices: %s"), devices.sort().join(", "))}
; + return device.description; }; - const MultipathInfo = () => { - if (device.type !== "multipath") return null; - - const wires = device.wires.map(deviceBaseName); - - // TRANSLATORS: multipath details, %s is replaced by list of connections used by the device - return
{sprintf(_("Wires: %s"), wires.sort().join(", "))}
; + const renderPTableType = (device) => { + const type = device.partitionTable?.type; + if (type) return {type.toUpperCase()}; }; return ( -
- - - - - - -
+
{renderContent(device)} {renderPTableType(device)}
); }; @@ -141,59 +106,10 @@ const DeviceExtendedInfo = ({ device }) => { * @component * * @param {object} props - * @param {StorageDevice} props.device + * @param {PartitionSlot|StorageDevice} props.item */ -const DeviceContentInfo = ({ device }) => { - const PTable = () => { - if (device.partitionTable === undefined) return null; - - const type = device.partitionTable.type.toUpperCase(); - const numPartitions = device.partitionTable.partitions.length; - - // TRANSLATORS: disk partition info, %s is replaced by partition table - // type (MS-DOS or GPT), %d is the number of the partitions - const text = sprintf(_("%s with %d partitions"), type, numPartitions); - - return ( -
- {text} -
- ); - }; - - const Systems = () => { - if (!device.systems || device.systems.length === 0) return null; - - const System = ({ system }) => { - const logo = /windows/i.test(system) ? "windows_logo" : "linux_logo"; - - return
{system}
; - }; - - return device.systems.map((s, i) => ); - }; - - // TODO: there is a lot of room for improvement here, but first we would need - // device.description (comes from YaST) to be way more granular - const Description = () => { - if (device.partitionTable) return null; - - if (!device.sid || (!!device.model && device.model === device.description)) { - // TRANSLATORS: status message, no existing content was found on the disk, - // i.e. the disk is completely empty - return
{_("No content found")}
; - } - - return
{device.description}
; - }; - - return ( -
- - - -
- ); +const DeviceSize = ({ item }) => { + return deviceSize(item.size); }; -export { DeviceContentInfo, DeviceExtendedInfo, FilesystemLabel }; +export { toStorageDevice, DeviceName, DeviceDetails, DeviceSize, FilesystemLabel }; diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index 763840a3b7..6432494fa2 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -31,7 +31,6 @@ export { default as DASDFormatProgress } from "./DASDFormatProgress"; export { default as ZFCPPage } from "./ZFCPPage"; export { default as ZFCPDiskForm } from "./ZFCPDiskForm"; export { default as ISCSIPage } from "./ISCSIPage"; -export { DeviceContentInfo, DeviceExtendedInfo, FilesystemLabel } from "./device-utils"; export { default as BootSelectionDialog } from "./BootSelectionDialog"; export { default as DeviceSelectionDialog } from "./DeviceSelectionDialog"; export { default as DeviceSelectorTable } from "./DeviceSelectorTable";