From 32c4639674380478cd02a91002002c146c41f22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz?= <1691872+dgdavid@users.noreply.github.com> Date: Mon, 18 Mar 2024 09:18:55 +0000 Subject: [PATCH] web: add Result section to Storage page (#1088) Replace the `Actions` section in the storage proposal for a `Result` one which presents the proposal result in a more appealing way displaying how the storage would look after installation instead of just the list of actions. Among others bits, it hints the user about things like file-systems are going to be created, resized actions and a subtle emphasis in deletions, if any. The full, plain list of actions is now available in a modal dialog linked right before the new introduced table. --- .../storage/interfaces/device/filesystem.rb | 12 +- .../interfaces/device/filesystem_examples.rb | 6 + service/test/fixtures/trivial_lvm.yml | 4 +- web/.eslintignore | 1 + web/cspell.json | 3 +- web/src/assets/styles/blocks.scss | 156 +- web/src/assets/styles/variables.scss | 4 + web/src/client/storage.js | 194 +-- web/src/client/storage.test.js | 3 +- web/src/components/core/Reminder.jsx | 6 +- web/src/components/core/Reminder.test.jsx | 6 + web/src/components/core/Tag.jsx | 41 + web/src/components/core/Tag.test.jsx | 33 + web/src/components/core/TreeTable.jsx | 128 ++ web/src/components/core/TreeTable.test.jsx | 24 + web/src/components/core/index.js | 2 + web/src/components/storage/DevicesManager.js | 215 +++ .../components/storage/DevicesManager.test.js | 433 +++++ ...sSection.jsx => ProposalActionsDialog.jsx} | 99 +- .../storage/ProposalActionsDialog.test.jsx | 144 ++ .../storage/ProposalActionsSection.test.jsx | 135 -- .../storage/ProposalDeviceSection.jsx | 14 +- web/src/components/storage/ProposalPage.jsx | 61 +- .../components/storage/ProposalPage.test.jsx | 6 +- .../storage/ProposalResultSection.jsx | 296 ++++ .../storage/ProposalResultSection.test.jsx | 133 ++ web/src/components/storage/index.js | 3 +- .../storage/test-data/full-result-example.js | 1446 +++++++++++++++++ web/src/components/storage/utils.js | 34 +- web/src/components/storage/utils.test.js | 61 + web/src/utils.js | 26 +- web/src/utils.test.js | 18 +- 32 files changed, 3395 insertions(+), 352 deletions(-) create mode 100644 web/src/components/core/Tag.jsx create mode 100644 web/src/components/core/Tag.test.jsx create mode 100644 web/src/components/core/TreeTable.jsx create mode 100644 web/src/components/core/TreeTable.test.jsx create mode 100644 web/src/components/storage/DevicesManager.js create mode 100644 web/src/components/storage/DevicesManager.test.js rename web/src/components/storage/{ProposalActionsSection.jsx => ProposalActionsDialog.jsx} (54%) create mode 100644 web/src/components/storage/ProposalActionsDialog.test.jsx delete mode 100644 web/src/components/storage/ProposalActionsSection.test.jsx create mode 100644 web/src/components/storage/ProposalResultSection.jsx create mode 100644 web/src/components/storage/ProposalResultSection.test.jsx create mode 100644 web/src/components/storage/test-data/full-result-example.js diff --git a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb b/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb index ad066f2022..545a179813 100644 --- a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb +++ b/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb @@ -45,6 +45,15 @@ def self.apply?(storage_device) FILESYSTEM_INTERFACE = "org.opensuse.Agama.Storage1.Filesystem" private_constant :FILESYSTEM_INTERFACE + # SID of the file system. + # + # It is useful to detect whether a file system is new. + # + # @return [Integer] + def filesystem_sid + storage_device.filesystem.sid + end + # File system type. # # @return [String] e.g., "ext4" @@ -68,7 +77,8 @@ def filesystem_label def self.included(base) base.class_eval do - dbus_interface FILESYSTEM_INTERFACE do + dbus_interface FILESYSTEM_INTERFACE do + dbus_reader :filesystem_sid, "u", dbus_name: "SID" dbus_reader :filesystem_type, "s", dbus_name: "Type" dbus_reader :filesystem_mount_path, "s", dbus_name: "MountPath" dbus_reader :filesystem_label, "s", dbus_name: "Label" diff --git a/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb b/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb index 84b8da4a10..71694a3ab7 100644 --- a/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb +++ b/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb @@ -28,6 +28,12 @@ let(:device) { devicegraph.find_by_name("/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1") } + describe "#filesystem_sid" do + it "returns the file system SID" do + expect(subject.filesystem_sid).to eq(45) + end + end + describe "#filesystem_type" do it "returns the file system type" do expect(subject.filesystem_type).to eq("ext4") diff --git a/service/test/fixtures/trivial_lvm.yml b/service/test/fixtures/trivial_lvm.yml index 86f3fc904e..ff7aec2699 100644 --- a/service/test/fixtures/trivial_lvm.yml +++ b/service/test/fixtures/trivial_lvm.yml @@ -6,7 +6,7 @@ partitions: - partition: - size: unlimited + size: 100 GiB name: /dev/sda1 id: lvm @@ -18,7 +18,7 @@ lvm_lvs: - lvm_lv: - size: unlimited + size: 100 GiB lv_name: lv1 file_system: btrfs mount_point: / diff --git a/web/.eslintignore b/web/.eslintignore index 8faa0e3fd2..fb9357ef5e 100644 --- a/web/.eslintignore +++ b/web/.eslintignore @@ -1,2 +1,3 @@ node_modules/* src/lib/* +src/**/test-data/* diff --git a/web/cspell.json b/web/cspell.json index 9c7619f4a9..3a2944fcda 100644 --- a/web/cspell.json +++ b/web/cspell.json @@ -5,7 +5,8 @@ "ignorePaths": [ "src/lib/cockpit.js", "src/lib/cockpit-po-plugin.js", - "src/manifest.json" + "src/manifest.json", + "src/**/test-data/*" ], "import": [ "@cspell/dict-css/cspell-ext.json", diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index e568ae24db..43b336bf31 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -403,6 +403,143 @@ ul[data-type="agama/list"][role="grid"] { } } +[data-type="agama/tag"] { + font-size: var(--fs-small); + + &[data-variant="teal"] { + color: var(--color-teal); + } + + &[data-variant="orange"] { + color: var(--color-orange); + } + + &[data-variant="gray-highlight"] { + padding: var(--spacer-smaller); + color: var(--color-gray-darkest); + background: var(--color-gray); + border: 1px solid var(--color-gray-dark); + border-radius: 5px; + margin-inline-start: var(--spacer-smaller); + } +} + +table[data-type="agama/tree-table"] { + th:first-child { + block-size: fit-content; + padding-inline-end: var(--spacer-normal); + } + + th.fit-content { + block-size: fit-content; + overflow: visible; + text-overflow: unset; + } + + /** + * Temporary PF/Table overrides for small devices + **/ + @media (width <= 768px) { + &.pf-m-tree-view-grid-md.pf-v5-c-table tr[aria-level="1"] td { + padding-inline-start: var(--spacer-medium); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr[aria-level="2"] th { + padding-inline-start: calc(var(--spacer-large) * 1.1); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr[aria-level="2"] td { + padding-inline-start: calc(var(--spacer-large) * 1.4); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr).pf-m-tree-view-details-expanded { + padding-block-end: var(--spacer-smaller); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td:empty, + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td *:empty, + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td:has(> *:empty) { + display: none; + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td:has(> *:not(:empty)) { + display: inherit; + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tbody:where(.pf-v5-c-table__tbody) tr:where(.pf-v5-c-table__tr)::before { + inset-inline-start: 0; + } + + &.pf-v5-c-table.pf-m-compact tr:where(.pf-v5-c-table__tr):not(.pf-v5-c-table__expandable-row) > *:last-child { + padding-inline-end: 8px; + } + + tbody th:first-child { + font-size: var(--fs-large); + padding-block-start: var(--spacer-small); + } + } +} + +table.proposal-result { + tr.dimmed-row { + background-color: #fff; + opacity: 0.8; + background: repeating-linear-gradient( -45deg, #fcfcff, #fcfcff 3px, #fff 3px, #fff 10px ); + + td { + color: var(--color-gray-dimmed); + padding-block: 0; + } + + } + + /** + * Temporary hack because the collapse/expand callback was not given to the + * tree table + **/ + th button { + display: none; + } + + tbody th .pf-v5-c-table__tree-view-main { + padding-inline-start: var(--pf-v5-c-table--m-compact--cell--first-last-child--PaddingLeft); + cursor: auto; + } + + tbody tr[aria-level="2"] th .pf-v5-c-table__tree-view-main { + padding-inline-start: calc( + var(--pf-v5-c-table--m-compact--cell--first-last-child--PaddingLeft) + var(--spacer-large) + ); + } + /** End of temporary hack */ + + @media (width > 768px) { + th.details-column { + padding-inline-start: calc(60px + var(--spacer-smaller) * 2); + } + + td.details-column { + display: grid; + gap: var(--spacer-smaller); + grid-template-columns: 60px 1fr; + + :first-child { + text-align: end; + } + } + + th.sizes-column, + td.sizes-column { + text-align: end; + + div.split { + justify-content: flex-end; + } + } + } +} + // compact lists in popover .pf-v5-c-popover li + li { margin: 0; @@ -486,7 +623,24 @@ ul[data-type="agama/list"][role="grid"] { h4 { color: var(--accent-color); - margin-block-end: var(--spacer-smaller); + } + + h4 ~ * { + margin-block-start: var(--spacer-small); + } +} + +section [data-type="agama/reminder"] { + margin-inline: 0; +} + +[data-type="agama/reminder"][data-variant="subtle"] { + --accent-color: var(--color-primary); + padding-block: 0; + border-inline-start-width: 1px; + + h4 { + font-size: var(--fs-normal); } } diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss index b25bfcbad3..490429fe54 100644 --- a/web/src/assets/styles/variables.scss +++ b/web/src/assets/styles/variables.scss @@ -51,8 +51,12 @@ --color-gray: #f2f2f2; --color-gray-dark: #efefef; // Fog --color-gray-darker: #999; + --color-gray-darkest: #333; --color-gray-dimmed: #888; --color-gray-dimmest: #666; + --color-teal: #279c9c; + --color-blue: #0d4ea6; + --color-orange: #e86427; --color-link: #0c322c; --color-link-hover: #30ba78; diff --git a/web/src/client/storage.js b/web/src/client/storage.js index b7d6135f4b..4c2b2153bd 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -47,6 +47,103 @@ const ZFCP_CONTROLLER_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Controller"; const ZFCP_DISKS_NAMESPACE = "/org/opensuse/Agama/Storage1/zfcp_disks"; const ZFCP_DISK_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Disk"; +/** + * @typedef {object} StorageDevice + * @property {number} sid - Storage ID + * @property {string} name - Device name + * @property {string} description - Device description + * @property {boolean} isDrive - Whether the device is a drive + * @property {string} type - Type of device (e.g., "disk", "raid", "multipath", "dasd", "md") + * @property {string} [vendor] + * @property {string} [model] + * @property {string[]} [driver] + * @property {string} [bus] + * @property {string} [busId] - DASD Bus ID (only for "dasd" type) + * @property {string} [transport] + * @property {boolean} [sdCard] + * @property {boolean} [dellBOOS] + * @property {string[]} [devices] - RAID devices (only for "raid" and "md" types) + * @property {string[]} [wires] - Multipath wires (only for "multipath" type) + * @property {string} [level] - MD RAID level (only for "md" type) + * @property {string} [uuid] + * @property {number} [start] - First block of the region (only for block devices) + * @property {boolean} [active] + * @property {boolean} [encrypted] - Whether the device is encrypted (only for block devices) + * @property {boolean} [isEFI] - Whether the device is an EFI partition (only for partition) + * @property {number} [size] + * @property {number} [recoverableSize] + * @property {string[]} [systems] - Name of the installed systems + * @property {string[]} [udevIds] + * @property {string[]} [udevPaths] + * @property {PartitionTable} [partitionTable] + * @property {Filesystem} [filesystem] + * @property {Component} [component] - When it is used as component of other devices + * @property {StorageDevice[]} [physicalVolumes] - Only for LVM VGs + * @property {StorageDevice[]} [logicalVolumes] - Only for LVM VGs + * + * @typedef {object} PartitionTable + * @property {string} type + * @property {StorageDevice[]} partitions + * @property {PartitionSlot[]} unusedSlots + * @property {number} unpartitionedSize - Total size not assigned to any partition + * + * @typedef {object} PartitionSlot + * @property {number} start + * @property {number} size + * + * @typedef {object} Component + * @property {string} type + * @property {string[]} deviceNames + * + * @typedef {object} Filesystem + * @property {number} sid + * @property {string} type + * @property {string} [mountPath] + * + * @typedef {object} ProposalResult + * @property {ProposalSettings} settings + * @property {Action[]} actions + * + * @typedef {object} Action + * @property {number} device + * @property {string} text + * @property {boolean} subvol + * @property {boolean} delete + * + * @typedef {object} ProposalSettings + * @property {string} bootDevice + * @property {string} encryptionPassword + * @property {string} encryptionMethod + * @property {boolean} lvm + * @property {string} spacePolicy + * @property {SpaceAction[]} spaceActions + * @property {string[]} systemVGDevices + * @property {Volume[]} volumes + * @property {StorageDevice[]} installationDevices + * + * @typedef {object} SpaceAction + * @property {string} device + * @property {string} action + * + * @typedef {object} Volume + * @property {string} mountPath + * @property {string} fsType + * @property {number} minSize + * @property {number} [maxSize] + * @property {boolean} autoSize + * @property {boolean} snapshots + * @property {boolean} transactional + * @property {VolumeOutline} outline + * + * @typedef {object} VolumeOutline + * @property {boolean} required + * @property {string[]} fsTypes + * @property {boolean} supportAutoSize + * @property {boolean} snapshotsConfigurable + * @property {boolean} snapshotsAffectSizes + * @property {string[]} sizeRelevantVolumes + */ + /** * Enum for the encryption method values * @@ -106,57 +203,6 @@ class DevicesManager { * Gets all the exported devices * * @returns {Promise} - * - * @typedef {object} StorageDevice - * @property {string} sid - Storage ID - * @property {string} name - Device name - * @property {string} description - Device description - * @property {boolean} isDrive - Whether the device is a drive - * @property {string} type - Type of device ("disk", "raid", "multipath", "dasd", "md") - * @property {string} [vendor] - * @property {string} [model] - * @property {string[]} [driver] - * @property {string} [bus] - * @property {string} [busId] - DASD Bus ID (only for "dasd" type) - * @property {string} [transport] - * @property {boolean} [sdCard] - * @property {boolean} [dellBOOS] - * @property {string[]} [devices] - RAID devices (only for "raid" and "md" types) - * @property {string[]} [wires] - Multipath wires (only for "multipath" type) - * @property {string} [level] - MD RAID level (only for "md" type) - * @property {string} [uuid] - * @property {number} [start] - First block of the region (only for block devices) - * @property {boolean} [active] - * @property {boolean} [encrypted] - Whether the device is encrypted (only for block devices) - * @property {boolean} [isEFI] - Whether the device is an EFI partition (only for partition) - * @property {number} [size] - * @property {number} [recoverableSize] - * @property {string[]} [systems] - Name of the installed systems - * @property {string[]} [udevIds] - * @property {string[]} [udevPaths] - * @property {PartitionTable} [partitionTable] - * @property {Filesystem} [filesystem] - * @property {Component} [component] - When it is used as component of other devices - * @property {StorageDevice[]} [physicalVolumes] - Only for LVM VGs - * @property {StorageDevice[]} [logicalVolumes] - Only for LVM VGs - * - * @typedef {object} PartitionTable - * @property {string} type - * @property {StorageDevice[]} partitions - * @property {PartitionSlot[]} unusedSlots - * @property {number} unpartitionedSize - Total size not assigned to any partition - * - * @typedef {object} PartitionSlot - * @property {number} start - * @property {number} size - * - * @typedef {object} Component - * @property {string} type - * @property {string[]} deviceNames - * - * @typedef {object} Filesystem - * @property {string} type - * @property {string} [mountPath] */ async getDevices() { const buildDevice = (path, dbusDevices) => { @@ -236,6 +282,7 @@ class DevicesManager { const buildMountPath = path => path.length > 0 ? path : undefined; const buildLabel = label => label.length > 0 ? label : undefined; device.filesystem = { + sid: filesystemProperties.SID.v, type: filesystemProperties.Type.v, mountPath: buildMountPath(filesystemProperties.MountPath.v), label: buildLabel(filesystemProperties.Label.v) @@ -329,42 +376,6 @@ class ProposalManager { }; } - /** - * @typedef {object} ProposalSettings - * @property {string} bootDevice - * @property {string} encryptionPassword - * @property {string} encryptionMethod - * @property {boolean} lvm - * @property {string} spacePolicy - * @property {SpaceAction[]} spaceActions - * @property {string[]} systemVGDevices - * @property {Volume[]} volumes - * @property {StorageDevice[]} installationDevices - * - * @typedef {object} SpaceAction - * @property {string} device - * @property {string} action - * - * @typedef {object} Volume - * @property {string} mountPath - * @property {string} fsType - * @property {number} minSize - * @property {number} [maxSize] - * @property {boolean} autoSize - * @property {boolean} snapshots - * @property {boolean} transactional - * @property {VolumeOutline} outline - * - * @typedef {object} VolumeOutline - * @property {boolean} required - * @property {string[]} fsTypes - * @property {boolean} supportAutoSize - * @property {boolean} adjustByRam - * @property {boolean} snapshotsConfigurable - * @property {boolean} snapshotsAffectSizes - * @property {string[]} sizeRelevantVolumes - */ - /** * Gets the list of available devices * @@ -421,15 +432,6 @@ class ProposalManager { * Gets the values of the current proposal * * @return {Promise} - * - * @typedef {object} ProposalResult - * @property {ProposalSettings} settings - * @property {Action[]} actions - * - * @typedef {object} Action - * @property {string} text - * @property {boolean} subvol - * @property {boolean} delete */ async getResult() { const proxy = await this.proposalProxy(); diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index d78dc64263..1604c04449 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -205,7 +205,7 @@ const md0 = { systems : ["openSUSE Leap 15.2"], udevIds: [], udevPaths: [], - filesystem: { type: "ext4", mountPath: "/test", label: "system" } + filesystem: { sid: 100, type: "ext4", mountPath: "/test", label: "system" } }; const raid = { @@ -866,6 +866,7 @@ const contexts = { UdevPaths: { t: "as", v: [] } }, "org.opensuse.Agama.Storage1.Filesystem": { + SID: { t: "u", v: 100 }, Type: { t: "s", v: "ext4" }, MountPath: { t: "s", v: "/test" }, Label: { t: "s", v: "system" } diff --git a/web/src/components/core/Reminder.jsx b/web/src/components/core/Reminder.jsx index 997e870a91..cd4e0943ad 100644 --- a/web/src/components/core/Reminder.jsx +++ b/web/src/components/core/Reminder.jsx @@ -62,7 +62,8 @@ const ReminderTitle = ({ children }) => { * @param {object} props * @param {string} [props.icon] - The name of desired icon. * @param {JSX.Element|string} [props.title] - The content for the title. - * @param {string} [props.role="status"] - The reminder's role, "status" by + * @param {string} [props.role="status"] - The reminder's role, "status" by default. + * @param {("subtle")} [props.variant] - The reminder's variant, none by default. * default. See {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role} * @param {JSX.Element} [props.children] - The content for the description. */ @@ -70,10 +71,11 @@ export default function Reminder ({ icon, title, role = "status", + variant, children }) { return ( -
+
{title} diff --git a/web/src/components/core/Reminder.test.jsx b/web/src/components/core/Reminder.test.jsx index 24527cf2d0..41ebfaf300 100644 --- a/web/src/components/core/Reminder.test.jsx +++ b/web/src/components/core/Reminder.test.jsx @@ -37,6 +37,12 @@ describe("Reminder", () => { within(reminder).getByText("Example"); }); + it("renders a region with given data-variant, if any", () => { + plainRender(Example); + const reminder = screen.getByRole("alert"); + expect(reminder).toHaveAttribute("data-variant", "subtle"); + }); + it("renders given title", () => { plainRender( Kindly reminder}> diff --git a/web/src/components/core/Tag.jsx b/web/src/components/core/Tag.jsx new file mode 100644 index 0000000000..c910d07295 --- /dev/null +++ b/web/src/components/core/Tag.jsx @@ -0,0 +1,41 @@ +/* + * 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"; + +/** + * Simple component that helps wrapped content stand out visually. The variant + * prop determines what kind of enhancement is applied. + * @component + * + * @param {object} props + * @param {("simple"|"teal"|"orange"|"gray-highlight")} [props.variant="simple"] + * @param {React.ReactNode} props.children + */ +export default function Tag ({ variant = "simple", children }) { + return ( + + {children} + + ); +} diff --git a/web/src/components/core/Tag.test.jsx b/web/src/components/core/Tag.test.jsx new file mode 100644 index 0000000000..e4a9739abf --- /dev/null +++ b/web/src/components/core/Tag.test.jsx @@ -0,0 +1,33 @@ +/* + * 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 } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { Tag } from "~/components/core"; + +describe("Tag", () => { + it("renders its children in a node with data-type='agama/tag' attribute", () => { + plainRender(New); + const node = screen.getByText("New"); + expect(node).toHaveAttribute("data-type", "agama/tag"); + }); +}); diff --git a/web/src/components/core/TreeTable.jsx b/web/src/components/core/TreeTable.jsx new file mode 100644 index 0000000000..5f7612adb8 --- /dev/null +++ b/web/src/components/core/TreeTable.jsx @@ -0,0 +1,128 @@ +/* + * 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 { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table'; + +/** + * @typedef {import("@patternfly/react-table").TableProps} TableProps + */ + +/** + * @typedef {object} TreeTableColumn + * @property {string} title + * @property {(any) => React.ReactNode} content + * @property {string} [classNames] + */ + +/** + * @typedef {object} TreeTableBaseProps + * @property {TreeTableColumn[]} columns=[] + * @property {object[]} items=[] + * @property {(any) => array} [itemChildren] + * @property {(any) => string} [rowClassNames] + */ + +/** + * Table built on top of PF/Table + * @component + * + * FIXME: omitting `ref` here to avoid a TypeScript error but keep component as + * typed as possible. Further investigation is needed. + * + * @typedef {TreeTableBaseProps & Omit} TreeTableProps + * + * @param {TreeTableProps} props + */ +export default function TreeTable({ + columns = [], + items = [], + itemChildren = () => [], + rowClassNames = () => "", + ...tableProps +}) { + const renderColumns = (item, treeRow) => { + return columns.map((c, cIdx) => { + const props = { + dataLabel: c.title, + className: c.classNames + }; + + if (cIdx === 0) props.treeRow = treeRow; + + return ( + {c.content(item)} + ); + }); + }; + + const renderRows = (items, level) => { + if (items?.length <= 0) return; + + return ( + items.map((item, itemIdx) => { + const children = itemChildren(item); + + const treeRow = { + props: { + isExpanded: true, + isDetailsExpanded: true, + "aria-level": level, + "aria-posinset": itemIdx + 1, + "aria-setsize": children?.length || 0 + } + }; + + const rowProps = { + row: { props: treeRow.props }, + className: rowClassNames(item) + }; + + return ( + + {renderColumns(item, treeRow)} + { renderRows(children, level + 1)} + + ); + }) + ); + }; + + return ( + + + + { columns.map((c, i) => ) } + + + + { renderRows(items, 1) } + +
{c.title}
+ ); +} diff --git a/web/src/components/core/TreeTable.test.jsx b/web/src/components/core/TreeTable.test.jsx new file mode 100644 index 0000000000..c1c858da16 --- /dev/null +++ b/web/src/components/core/TreeTable.test.jsx @@ -0,0 +1,24 @@ +/* + * 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. + */ + +describe("TreeTable", () => { + it.todo("add examples for testing core/TreeTable component"); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 6bdac86248..f6448c03d3 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -58,3 +58,5 @@ export { default as DevelopmentInfo } from "./DevelopmentInfo"; export { default as Selector } from "./Selector"; export { default as OptionsPicker } from "./OptionsPicker"; export { default as Reminder } from "./Reminder"; +export { default as Tag } from "./Tag"; +export { default as TreeTable } from "./TreeTable"; diff --git a/web/src/components/storage/DevicesManager.js b/web/src/components/storage/DevicesManager.js new file mode 100644 index 0000000000..5f70318564 --- /dev/null +++ b/web/src/components/storage/DevicesManager.js @@ -0,0 +1,215 @@ +/* + * 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 { compact, uniq } from "~/utils"; + +/** + * @typedef {import ("~/client/storage").Action} Action + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * Class for managing storage devices. + */ +export default class DevicesManager { + /** + * @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. + */ + constructor(system, staging, actions) { + this.system = system; + this.staging = staging; + this.actions = actions; + } + + /** + * System device with the given SID. + * + * @param {Number} sid + * @returns {StorageDevice|undefined} + */ + systemDevice(sid) { + return this.#device(sid, this.system); + } + + /** + * Staging device with the given SID. + * + * @param {Number} sid + * @returns {StorageDevice|undefined} + */ + stagingDevice(sid) { + return this.#device(sid, this.staging); + } + + /** + * Whether the given device exists in system. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + existInSystem(device) { + return this.#exist(device, this.system); + } + + /** + * Whether the given device exists in staging. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + existInStaging(device) { + return this.#exist(device, this.staging); + } + + /** + * Whether the given device is going to be formatted. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + hasNewFilesystem(device) { + if (!device.filesystem) return false; + + const systemDevice = this.systemDevice(device.sid); + const systemFilesystemSID = systemDevice?.filesystem?.sid; + + return device.filesystem.sid !== systemFilesystemSID; + } + + /** + * Whether the given device is going to be shrunk. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + isShrunk(device) { + return this.shrinkSize(device) > 0; + } + + /** + * Amount of bytes the given device is going to be shrunk. + * + * @param {StorageDevice} device + * @returns {Number} + */ + shrinkSize(device) { + const systemDevice = this.systemDevice(device.sid); + const stagingDevice = this.stagingDevice(device.sid); + + if (!systemDevice || !stagingDevice) return 0; + + const amount = systemDevice.size - stagingDevice.size; + return amount > 0 ? amount : 0; + } + + /** + * Disk devices and LVM volume groups used for the installation. + * + * @note The used devices are extracted from the actions. + * + * @returns {StorageDevice[]} + */ + usedDevices() { + const isTarget = (device) => device.isDrive || device.type === "lvmVg"; + + // Check in system devices to detect removals. + const targetSystem = this.system.filter(isTarget); + const targetStaging = this.staging.filter(isTarget); + + const sids = targetSystem.concat(targetStaging) + .filter(d => this.#isUsed(d)) + .map(d => d.sid); + + return compact(uniq(sids).map(sid => this.stagingDevice(sid))); + } + + /** + * Devices deleted. + * + * @note The devices are extracted from the actions. + * + * @returns {StorageDevice[]} + */ + deletedDevices() { + return this.#deleteActionsDevice().filter(d => !d.isDrive); + } + + /** + * Systems deleted. + * + * @returns {string[]} + */ + deletedSystems() { + const systems = this.#deleteActionsDevice() + .filter(d => !d.partitionTable) + .map(d => d.systems) + .flat(); + return compact(systems); + } + + /** + * @param {number} sid + * @param {StorageDevice[]} source + * @returns {StorageDevice|undefined} + */ + #device(sid, source) { + return source.find(d => d.sid === sid); + } + + /** + * @param {StorageDevice} device + * @param {StorageDevice[]} source + * @returns {boolean} + */ + #exist(device, source) { + return this.#device(device.sid, source) !== undefined; + } + + /** + * @param {StorageDevice} device + * @returns {boolean} + */ + #isUsed(device) { + const sids = uniq(compact(this.actions.map(a => a.device))); + + const partitions = device.partitionTable?.partitions || []; + const lvmLvs = device.logicalVolumes || []; + + return sids.includes(device.sid) || + partitions.find(p => this.#isUsed(p)) !== undefined || + lvmLvs.find(l => this.#isUsed(l)) !== undefined; + } + + /** + * @returns {StorageDevice[]} + */ + #deleteActionsDevice() { + const sids = this.actions + .filter(a => a.delete) + .map(a => a.device); + const devices = sids.map(sid => this.systemDevice(sid)); + return compact(devices); + } +} diff --git a/web/src/components/storage/DevicesManager.test.js b/web/src/components/storage/DevicesManager.test.js new file mode 100644 index 0000000000..23636fc106 --- /dev/null +++ b/web/src/components/storage/DevicesManager.test.js @@ -0,0 +1,433 @@ +/* + * 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 DevicesManager from "./DevicesManager"; + +let system; +let staging; +let actions; + +beforeEach(() => { + system = []; + staging = []; + actions = []; +}); + +describe("systemDevice", () => { + beforeEach(() => { + staging = [{ sid: 60, name: "/dev/sda" }]; + }); + + describe("if there is no system device with the given SID", () => { + it("returns undefined", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.systemDevice(60)).toBeUndefined(); + }); + }); + + describe("if there is a system device with the given SID", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sdb" }]; + }); + + it("returns the system device", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.systemDevice(60).name).toEqual("/dev/sdb"); + }); + }); +}); + +describe("stagingDevice", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda" }]; + }); + + describe("if there is no staging device with the given SID", () => { + it("returns undefined", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.stagingDevice(60)).toBeUndefined(); + }); + }); + + describe("if there is a staging device with the given SID", () => { + beforeEach(() => { + staging = [{ sid: 60, name: "/dev/sdb" }]; + }); + + it("returns the staging device", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.stagingDevice(60).name).toEqual("/dev/sdb"); + }); + }); +}); + +describe("existInSystem", () => { + beforeEach(() => { + system = [{ sid: 61, name: "/dev/sda2" }]; + staging = [{ sid: 60, name: "/dev/sda1" }]; + }); + + describe("if the given device does not exist in system", () => { + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInSystem({ sid: 60 })).toEqual(false); + }); + }); + + describe("if the given device exists in system", () => { + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInSystem({ sid: 61 })).toEqual(true); + }); + }); +}); + +describe("existInStaging", () => { + beforeEach(() => { + system = [{ sid: 61, name: "/dev/sda2" }]; + staging = [{ sid: 60, name: "/dev/sda1" }]; + }); + + describe("if the given device does not exist in staging", () => { + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInStaging({ sid: 61 })).toEqual(false); + }); + }); + + describe("if the given device exists in staging", () => { + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInStaging({ sid: 60 })).toEqual(true); + }); + }); +}); + +describe("hasNewFilesystem", () => { + describe("if the given device has no file system", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + staging = [{ sid: 60, name: "/dev/sda1" }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.hasNewFilesystem(device)).toEqual(false); + }); + }); + + describe("if the given device has no new file system", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + staging = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.hasNewFilesystem(device)).toEqual(false); + }); + }); + + describe("if the given device has a new file system", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + staging = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 62 } }]; + }); + + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.hasNewFilesystem(device)).toEqual(true); + }); + }); +}); + +describe("isShrunk", () => { + describe("if the device is new", () => { + beforeEach(() => { + system = []; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the device does not exist anymore", () => { + beforeEach(() => { + system = [{ sid: 60, size: 2048 }]; + staging = []; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.systemDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the size is kept", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 1024 }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the size is more than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the size is less than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 512 }]; + }); + + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(true); + }); + }); +}); + +describe("shrinkSize", () => { + describe("if the device is new", () => { + beforeEach(() => { + system = []; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the device does not exist anymore", () => { + beforeEach(() => { + system = [{ sid: 60, size: 2048 }]; + staging = []; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.systemDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the size is kept", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 1024 }]; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the size is more than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the size is less than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 512 }]; + }); + + it("returns the shrink amount", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(512); + }); + }); +}); + +describe("usedDevices", () => { + beforeEach(() => { + system = [ + { sid: 60, isDrive: false }, + { sid: 61, isDrive: true }, + { sid: 62, isDrive: true, partitionTable: { partitions: [{ sid: 67 }] } }, + { sid: 63, isDrive: true, partitionTable: { partitions: [] } }, + { sid: 64, isDrive: false, type: "lvmVg", logicalVolumes: [] }, + { sid: 65, isDrive: false, type: "lvmVg", logicalVolumes: [] }, + { sid: 66, isDrive: false, type: "lvmVg", logicalVolumes: [{ sid: 68 }] } + ]; + staging = [ + { sid: 60, isDrive: false }, + // Partition removed + { sid: 62, isDrive: true, partitionTable: { partitions: [] } }, + // Partition added + { sid: 63, isDrive: true, partitionTable: { partitions: [{ sid: 69 }] } }, + { sid: 64, isDrive: false, type: "lvmVg", logicalVolumes: [] }, + // Logical volume added + { sid: 65, isDrive: false, type: "lvmVg", logicalVolumes: [{ sid: 70 }, { sid: 71 }] }, + // Logical volume removed + { sid: 66, isDrive: false, type: "lvmVg", logicalVolumes: [] } + ]; + }); + + describe("if there are no actions", () => { + beforeEach(() => { + actions = []; + }); + + it("returns an empty list", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.usedDevices()).toEqual([]); + }); + }); + + describe("if there are actions", () => { + beforeEach(() => { + actions = [ + // This device is ignored because it is neither a drive nor a LVM VG. + { device: 60 }, + // This device was removed. + { device: 61 }, + // This partition was removed (belongs to device 62). + { device: 67 }, + // This logical volume was removed (belongs to device 66). + { device: 68 }, + // This partition was added (belongs to device 63). + { device: 69 }, + // This logical volume was added (belongs to device 65). + { device: 70 }, + // This logical volume was added (belongs to device 65). + { device: 71 } + ]; + }); + + it("does not include removed disk devices or LVM volume groups", () => { + const manager = new DevicesManager(system, staging, actions); + const sids = manager.usedDevices().map(d => d.sid) + .sort(); + expect(sids).not.toContain(61); + }); + + it("includes all disk devices and LVM volume groups affected by the actions", () => { + const manager = new DevicesManager(system, staging, actions); + const sids = manager.usedDevices().map(d => d.sid) + .sort(); + expect(sids).toEqual([62, 63, 65, 66]); + }); + }); +}); + +describe("deletedDevices", () => { + beforeEach(() => { + system = [ + { sid: 60 }, + { sid: 62 }, + { sid: 63 }, + { sid: 64 }, + { sid: 65, isDrive: true } + ]; + actions = [ + { device: 60, delete: true }, + // This device does not exist in system. + { device: 61, delete: true }, + { device: 62, delete: false }, + { device: 63, delete: true }, + { device: 65, delete: true } + ]; + }); + + it("includes all deleted devices", () => { + const manager = new DevicesManager(system, staging, actions); + const sids = manager.deletedDevices().map(d => d.sid) + .sort(); + expect(sids).toEqual([60, 63]); + }); +}); + +describe("deletedSystems", () => { + beforeEach(() => { + system = [ + { sid: 60, systems: ["Windows XP"] }, + { sid: 62, systems: ["Ubuntu"] }, + { + sid: 63, + systems: ["openSUSE Leap", "openSUSE Tumbleweed"], + partitionTable: { + partitions: [{ sid: 65 }, { sid: 66 }] + } + }, + { sid: 64 }, + { sid: 65, systems: ["openSUSE Leap"] }, + { sid: 66, systems: ["openSUSE Tumbleweed"] } + ]; + actions = [ + { device: 60, delete: true }, + // This device does not exist in system. + { device: 61, delete: true }, + { device: 62, delete: false }, + { device: 63, delete: true }, + { device: 65, delete: true }, + { device: 66, delete: true } + ]; + }); + + it("includes all deleted systems", () => { + const manager = new DevicesManager(system, staging, actions); + const systems = manager.deletedSystems(); + expect(systems.length).toEqual(3); + expect(systems).toContain("Windows XP"); + expect(systems).toContain("openSUSE Leap"); + expect(systems).toContain("openSUSE Tumbleweed"); + }); +}); diff --git a/web/src/components/storage/ProposalActionsSection.jsx b/web/src/components/storage/ProposalActionsDialog.jsx similarity index 54% rename from web/src/components/storage/ProposalActionsSection.jsx rename to web/src/components/storage/ProposalActionsDialog.jsx index 8d35563afd..053f9d852c 100644 --- a/web/src/components/storage/ProposalActionsSection.jsx +++ b/web/src/components/storage/ProposalActionsDialog.jsx @@ -20,21 +20,12 @@ */ import React, { useState } from "react"; -import { - List, - ListItem, - ExpandableSection, - Skeleton, -} from "@patternfly/react-core"; +import { List, ListItem, ExpandableSection, } from "@patternfly/react-core"; import { sprintf } from "sprintf-js"; - import { _, n_ } from "~/i18n"; -import { If, Section } from "~/components/core"; import { partition } from "~/utils"; +import { If, Popup } from "~/components/core"; -// TODO: would be nice adding an aria-description to these lists, but aria-description still in -// draft yet and aria-describedby should be used... which id not ideal right now -// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description const ActionsList = ({ actions }) => { // Some actions (e.g., deleting a LV) are reported as several actions joined by a line break const actionItems = (action, id) => { @@ -53,15 +44,21 @@ const ActionsList = ({ actions }) => { }; /** - * Renders the list of actions to perform in the system + * Renders a dialog with the given list of actions * @component * * @param {object} props - * @param {object[]} [props.actions=[]] + * @param {object[]} [props.actions=[]] - The actions to perform in the system. + * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. + * @param {function} props.onClose - Whether the dialog is visible or not. */ -const ProposalActions = ({ actions = [] }) => { +export default function ProposalActionsDialog({ actions = [], isOpen = false, onClose }) { const [isExpanded, setIsExpanded] = useState(false); + if (typeof onClose !== 'function') { + console.error("Missing ProposalActionsDialog#onClose callback"); + } + if (actions.length === 0) return null; const [generalActions, subvolActions] = partition(actions, a => !a.subvol); @@ -72,66 +69,32 @@ const ProposalActions = ({ actions = [] }) => { : sprintf(n_("Show %d subvolume action", "Show %d subvolume actions", subvolActions.length), subvolActions.length); return ( - <> - - {subvolActions.length > 0 && ( - setIsExpanded(!isExpanded)} - toggleText={toggleText} - className="expandable-actions" - > - - - )} - - ); -}; - -/** - * @todo Create a component for rendering a customized skeleton - */ -const ActionsSkeleton = () => { - return ( - <> - - - - - - - ); -}; - -/** - * Section with the actions to perform in the system - * @component - * - * @param {object} props - * @param {object[]} [props.actions=[]] - * @param {string[]} [props.errors=[]] - * @param {boolean} [props.isLoading=false] - Whether the section content should be rendered as loading - */ -export default function ProposalActionsSection({ actions = [], errors = [], isLoading = false }) { - if (isLoading) errors = []; - - return ( -
+ } - else={} + condition={subvolActions.length > 0} + then={ + setIsExpanded(!isExpanded)} + toggleText={toggleText} + className="expandable-actions" + > + + + } /> -
+ + {_("Close")} + + ); } diff --git a/web/src/components/storage/ProposalActionsDialog.test.jsx b/web/src/components/storage/ProposalActionsDialog.test.jsx new file mode 100644 index 0000000000..cde3a95c51 --- /dev/null +++ b/web/src/components/storage/ProposalActionsDialog.test.jsx @@ -0,0 +1,144 @@ +/* + * Copyright (c) [2022-2023] 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, waitForElementToBeRemoved } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProposalActionsDialog } from "~/components/storage"; + +const actions = [ + { text: 'Create GPT on /dev/vdc', subvol: false, delete: false }, + { text: 'Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition', subvol: false, delete: false }, + { text: 'Create encrypted partition /dev/vdc2 (29.99 GiB) as LVM physical volume', subvol: false, delete: false }, + { text: 'Create volume group system0 (29.98 GiB) with /dev/mapper/cr_vdc2 (29.99 GiB)', subvol: false, delete: false }, + { text: 'Create LVM logical volume /dev/system0/root (20.00 GiB) on volume group system0 for / with btrfs', subvol: false, delete: false }, +]; + +const subvolumeActions = [ + { text: 'Create subvolume @ on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/var on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/usr/local on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/srv on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/root on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/opt on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/home on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/boot/writable on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/boot/grub2/x86_64-efi on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/boot/grub2/i386-pc on /dev/system0/root (20.00 GiB)', subvol: true, delete: false } +]; + +const destructiveAction = { text: 'Delete ext4 on /dev/vdc', subvol: false, delete: true }; + +const onCloseFn = jest.fn(); + +it("renders nothing by default", () => { + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); +}); + +it("renders nothing when isOpen=false", () => { + const { container } = plainRender( + + ); + expect(container).toBeEmptyDOMElement(); +}); + +describe("when isOpen", () => { + it("renders nothing if there are no actions", () => { + plainRender(); + + expect(screen.queryAllByText(/Delete/)).toEqual([]); + expect(screen.queryAllByText(/Create/)).toEqual([]); + expect(screen.queryAllByText(/Show/)).toEqual([]); + }); + + describe("and there are actions", () => { + it("renders a dialog with the list of actions", () => { + plainRender(); + + const dialog = screen.getByRole("dialog", { name: "Planned Actions" }); + const actionsList = within(dialog).getByRole("list"); + const actionsListItems = within(actionsList).getAllByRole("listitem"); + expect(actionsListItems.map(i => i.textContent)).toEqual(actions.map(a => a.text)); + }); + + it("triggers the onClose callback when user clicks the Close button", async () => { + const { user } = plainRender(); + const closeButton = screen.getByRole("button", { name: "Close" }); + + await user.click(closeButton); + + expect(onCloseFn).toHaveBeenCalled(); + }); + + describe("when there is a destructive action", () => { + it("emphasizes the action", () => { + plainRender( + + ); + + // https://stackoverflow.com/a/63080940 + const actionItems = screen.getAllByRole("listitem"); + const destructiveActionItem = actionItems.find(item => item.textContent === destructiveAction.text); + + expect(destructiveActionItem).toHaveClass("proposal-action--delete"); + }); + }); + + describe("when there are subvolume actions", () => { + it("does not render the subvolume actions", () => { + plainRender( + + ); + + // For now, we know that there are two lists and the subvolume list is the second one. + // The test could be simplified once we have aria-descriptions for the lists. + const [genericList, subvolList] = screen.getAllByRole("list", { hidden: true }); + expect(genericList).not.toBeNull(); + expect(subvolList).not.toBeNull(); + const subvolItems = within(subvolList).queryAllByRole("listitem"); + expect(subvolItems).toEqual([]); + }); + + it("renders the subvolume actions after clicking on 'show subvolumes'", async () => { + const { user } = plainRender( + + ); + + const link = screen.getByText(/Show.*subvolume actions/); + + expect(screen.getAllByRole("list").length).toEqual(1); + + await user.click(link); + + waitForElementToBeRemoved(link); + screen.getByText(/Hide.*subvolume actions/); + + // For now, we know that there are two lists and the subvolume list is the second one. + // The test could be simplified once we have aria-descriptions for the lists. + const [, subvolList] = screen.getAllByRole("list"); + const subvolItems = within(subvolList).getAllByRole("listitem"); + + expect(subvolItems.map(i => i.textContent)).toEqual(subvolumeActions.map(a => a.text)); + }); + }); + }); +}); diff --git a/web/src/components/storage/ProposalActionsSection.test.jsx b/web/src/components/storage/ProposalActionsSection.test.jsx deleted file mode 100644 index 9864391d0d..0000000000 --- a/web/src/components/storage/ProposalActionsSection.test.jsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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, waitForElementToBeRemoved } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { ProposalActionsSection } from "~/components/storage"; - -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PFSkeleton
- }; -}); - -const actions = [ - { text: 'Create GPT on /dev/vdc', subvol: false, delete: false }, - { text: 'Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition', subvol: false, delete: false }, - { text: 'Create encrypted partition /dev/vdc2 (29.99 GiB) as LVM physical volume', subvol: false, delete: false }, - { text: 'Create volume group system0 (29.98 GiB) with /dev/mapper/cr_vdc2 (29.99 GiB)', subvol: false, delete: false }, - { text: 'Create LVM logical volume /dev/system0/root (20.00 GiB) on volume group system0 for / with btrfs', subvol: false, delete: false }, -]; - -const subvolumeActions = [ - { text: 'Create subvolume @ on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/var on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/usr/local on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/srv on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/root on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/opt on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/home on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/boot/writable on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/boot/grub2/x86_64-efi on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/boot/grub2/i386-pc on /dev/system0/root (20.00 GiB)', subvol: true, delete: false } -]; - -const destructiveAction = { text: 'Delete ext4 on /dev/vdc', subvol: false, delete: true }; - -it("renders skeleton while loading", () => { - plainRender(); - - screen.getAllByText(/PFSkeleton/); -}); - -it("renders nothing when there is no actions", () => { - plainRender(); - - expect(screen.queryAllByText(/Delete/)).toEqual([]); - expect(screen.queryAllByText(/Create/)).toEqual([]); - expect(screen.queryAllByText(/Show/)).toEqual([]); -}); - -describe("when there are actions", () => { - it("renders an explanatory text", () => { - plainRender(); - - screen.getByText(/Actions to create/); - }); - - it("renders the list of actions", () => { - plainRender(); - - const actionsList = screen.getByRole("list"); - const actionsListItems = within(actionsList).getAllByRole("listitem"); - expect(actionsListItems.map(i => i.textContent)).toEqual(actions.map(a => a.text)); - }); - - describe("when there is a destructive action", () => { - it("emphasizes the action", () => { - plainRender(); - - // https://stackoverflow.com/a/63080940 - const actionItems = screen.getAllByRole("listitem"); - const destructiveActionItem = actionItems.find(item => item.textContent === destructiveAction.text); - - expect(destructiveActionItem).toHaveClass("proposal-action--delete"); - }); - }); - - describe("when there are subvolume actions", () => { - it("does not render the subvolume actions", () => { - plainRender(); - - // For now, we know that there are two lists and the subvolume list is the second one. - // The test could be simplified once we have aria-descriptions for the lists. - const [genericList, subvolList] = screen.getAllByRole("list", { hidden: true }); - expect(genericList).not.toBeNull(); - expect(subvolList).not.toBeNull(); - const subvolItems = within(subvolList).queryAllByRole("listitem"); - expect(subvolItems).toEqual([]); - }); - - it("renders the subvolume actions after clicking on 'show subvolumes'", async () => { - const { user } = plainRender( - - ); - - const link = screen.getByText(/Show.*subvolume actions/); - - expect(screen.getAllByRole("list").length).toEqual(1); - - await user.click(link); - - waitForElementToBeRemoved(link); - screen.getByText(/Hide.*subvolume actions/); - - // For now, we know that there are two lists and the subvolume list is the second one. - // The test could be simplified once we have aria-descriptions for the lists. - const [, subvolList] = screen.getAllByRole("list"); - const subvolItems = within(subvolList).getAllByRole("listitem"); - - expect(subvolItems.map(i => i.textContent)).toEqual(subvolumeActions.map(a => a.text)); - }); - }); -}); diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx index df37eaf304..a39f4e333a 100644 --- a/web/src/components/storage/ProposalDeviceSection.jsx +++ b/web/src/components/storage/ProposalDeviceSection.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Form, @@ -101,7 +101,7 @@ const InstallationDeviceField = ({ isLoading = false, onChange = noop }) => { - const [device, setDevice] = useState(devices.find(d => d.name === current)); + const [device, setDevice] = useState(); const [isFormOpen, setIsFormOpen] = useState(false); const openForm = () => setIsFormOpen(true); @@ -114,6 +114,10 @@ const InstallationDeviceField = ({ onChange(selectedDevice); }; + useEffect(() => { + setDevice(devices.find(d => d.name === current)); + }, [current, devices, setDevice]); + /** * Renders a button that allows changing selected device * @@ -292,7 +296,7 @@ const LVMField = ({ isLoading = false, onChange: onChangeProp = noop }) => { - const [isChecked, setIsChecked] = useState(isCheckedProp); + const [isChecked, setIsChecked] = useState(); const [isFormOpen, setIsFormOpen] = useState(false); const [isFormValid, setIsFormValid] = useState(true); @@ -312,6 +316,10 @@ const LVMField = ({ onChangeProp({ vgDevices }); }; + useEffect(() => { + setIsChecked(isCheckedProp); + }, [isCheckedProp, setIsChecked]); + const description = _("Configuration of the system volume group. All the file systems will be \ created in a logical volume of the system volume group."); diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index c3d4753623..f17568c08b 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -26,11 +26,11 @@ import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; import { Page } from "~/components/core"; import { - ProposalActionsSection, ProposalPageMenu, - ProposalSettingsSection, ProposalDeviceSection, - ProposalTransactionalInfo + ProposalTransactionalInfo, + ProposalSettingsSection, + ProposalResultSection } from "~/components/storage"; import { IDLE } from "~/client/status"; @@ -216,40 +216,33 @@ export default function ProposalPage() { calculate(newSettings).catch(console.error); }; - const PageContent = () => { - return ( - <> - - - - - - ); - }; - return ( - // TRANSLATORS: page title + // TRANSLATORS: Storage page title - + + + + ); } diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index e47906c18b..7bd4f44a31 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -84,7 +84,7 @@ const storageMock = { getProductMountPoints: jest.fn().mockResolvedValue([]), getResult: jest.fn().mockResolvedValue(undefined), defaultVolume: jest.fn(mountPath => Promise.resolve({ mountPath })), - calculate: jest.fn().mockResolvedValue(0) + calculate: jest.fn().mockResolvedValue(0), }, system: { getDevices: jest.fn().mockResolvedValue([vda, vdb]) @@ -129,12 +129,12 @@ it("loads the proposal data", async () => { await screen.findByText(/\/dev\/vda/); }); -it("renders the device, settings and actions sections", async () => { +it("renders the device, settings, find space and result sections", async () => { installerRender(); await screen.findByText(/Device/); await screen.findByText(/Settings/); - await screen.findByText(/Planned Actions/); + await screen.findByText(/Result/); }); describe("when the storage devices become deprecated", () => { diff --git a/web/src/components/storage/ProposalResultSection.jsx b/web/src/components/storage/ProposalResultSection.jsx new file mode 100644 index 0000000000..3759c25740 --- /dev/null +++ b/web/src/components/storage/ProposalResultSection.jsx @@ -0,0 +1,296 @@ +/* + * 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, { 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 } from "~/components/storage"; + +/** + * @typedef {import ("~/client/storage").Action} Action + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import("~/client/mixins").ValidationError} ValidationError + */ + +/** + * Renders information about planned actions, allowing to check all of them and warning with a + * summary about the deletion ones, if any. + * @component + * + * @param {object} props + * @param {Action[]} props.actions + * @param {string[]} props.systems + */ +const DeletionsInfo = ({ actions, systems }) => { + const total = actions.length; + + if (total === 0) return; + + // TRANSLATORS: %d will be replaced by the amount of destructive actions + const warningTitle = sprintf(n_( + "There is %d destructive action planned", + "There are %d destructive actions planned", + total + ), total); + + // FIXME: Use the Intl.ListFormat instead of the `join(", ")` used below. + // Most probably, a `listFormat` or similar wrapper should live in src/i18n.js or so. + // Read https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat + return ( + + 0} + then={ +

+ { + // TRANSLATORS: This is part of a sentence to hint the user about affected systems. + // Eg. "Affecting Windows 11, openSUSE Leap 15, and Ubuntu 22.04" + } + {_("Affecting")} {systems.join(", ")} +

+ } + /> +
+ ); +}; + +/** + * Renders needed UI elements to allow user check the proposal planned actions + * @component + * + * @param {object} props + * @param {Action[]} props.actions + */ +const ActionsInfo = ({ actions }) => { + const [showActions, setShowActions] = useState(false); + const onOpen = () => setShowActions(true); + const onClose = () => setShowActions(false); + + return ( + <> + + + + ); +}; + +/** + * 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 renderFilesystemLabel = (item) => { + const label = item.filesystem?.label; + if (label) return {label}; + }; + + const renderPTableType = (item) => { + // TODO: Create a map for partition table types and use an here. + const type = item.partitionTable?.type; + if (type) return {type.toUpperCase()}; + }; + + const renderDetails = (item) => { + return ( + <> +
{renderNewLabel(item)}
+
{renderContent(item)} {renderFilesystemLabel(item)} {renderPTableType(item)}
+ + ); + }; + + const renderResizedLabel = (item) => { + if (!item.sid || !devicesManager.isShrunk(item)) return; + + return ( + + { + // TRANSLATORS: a label to show how much a device was resized. %s will be + // replaced with such a size, including the unit. E.g., 508 MiB + sprintf(_("Resized %s"), deviceSize(devicesManager.shrinkSize(item))) + } + + ); + }; + + const renderSize = (item) => { + return ( +
+ {renderResizedLabel(item)} + {deviceSize(item.size)} +
+ ); + }; + + const renderMountPoint = (item) => item.sid && {item.filesystem?.mountPath}; + + return ( + deviceChildren(d)} + rowClassNames={(item) => { + if (!item.sid) return "dimmed-row"; + }} + className="proposal-result" + /> + ); +}; + +/** + * @todo Create a component for rendering a customized skeleton + */ +const ResultSkeleton = () => { + return ( + <> + + + + + ); +}; + +/** + * Content of the section. + * @component + * + * @param {object} props + * @param {StorageDevice[]} props.system + * @param {StorageDevice[]} props.staging + * @param {Action[]} props.actions + * @param {ValidationError[]} props.errors + */ +const SectionContent = ({ system, staging, actions, errors }) => { + if (errors.length) return; + + const devicesManager = new DevicesManager(system, staging, actions); + + return ( + <> + a.delete && !a.subvol)} + systems={devicesManager.deletedSystems()} + /> + + + + ); +}; + +/** + * 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 + */ +export default function ProposalResultSection({ + system = [], + staging = [], + actions = [], + errors = [], + isLoading = false +}) { + if (isLoading) errors = []; + const totalActions = actions.length; + + // TRANSLATORS: The description for the Result section in storage proposal + // page. %d will be replaced by the number of proposal actions. + const description = sprintf(n_( + "During installation, %d action will be performed to configure the system as displayed below", + "During installation, %d actions will be performed to configure the system as displayed below", + totalActions + ), totalActions); + + return ( +
+ } + else={ + + } + /> +
+ ); +} diff --git a/web/src/components/storage/ProposalResultSection.test.jsx b/web/src/components/storage/ProposalResultSection.test.jsx new file mode 100644 index 0000000000..7e96795516 --- /dev/null +++ b/web/src/components/storage/ProposalResultSection.test.jsx @@ -0,0 +1,133 @@ +/* + * 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 { ProposalResultSection } from "~/components/storage"; +import { devices, actions } from "./test-data/full-result-example"; + +const errorMessage = "Something went wrong, proposal not possible"; +const errors = [{ severity: 0, message: errorMessage }]; +const defaultProps = { system: devices.system, staging: devices.staging, actions }; + +describe("ProposalResultSection", () => { + describe("when there are errors (proposal was not possible)", () => { + it("renders given errors", () => { + plainRender(); + expect(screen.queryByText(errorMessage)).toBeInTheDocument(); + }); + + it("does not render a warning for delete actions", () => { + plainRender(); + expect(screen.queryByText(/Warning alert:/)).toBeNull(); + }); + + it("does not render a treegrid node", () => { + plainRender(); + expect(screen.queryByRole("treegrid")).toBeNull(); + }); + + it("does not render the link for opening the planned actions dialog", () => { + plainRender(); + expect(screen.queryByRole("button", { name: /planned actions/ })).toBeNull(); + }); + }); + + describe("when there are no errors (proposal was possible)", () => { + it("does not render a warning when there are not delete actions", () => { + const props = { + ...defaultProps, + actions: defaultProps.actions.filter(a => !a.delete) + }; + + plainRender(); + expect(screen.queryByText(/Warning alert:/)).toBeNull(); + }); + + it("renders a reminder when there are delete actions", () => { + plainRender(); + const reminder = screen.getByRole("status"); + within(reminder).getByText(/4 destructive/); + }); + + it("renders the affected systems in the deletion reminder, if any", () => { + // NOTE: simulate the deletion of vdc2 (sid: 79) for checking that + // affected systems are rendered in the warning summary + const props = { + ...defaultProps, + actions: [{ device: 79, delete: true }] + }; + + plainRender(); + // FIXME: below line reveals that warning wrapper deserves a role or + // something + const reminder = screen.getByRole("status"); + within(reminder).getByText(/openSUSE/); + }); + + it("renders a treegrid including all relevant information about final result", () => { + plainRender(); + const treegrid = screen.getByRole("treegrid"); + /** + * Expected rows for full-result-example + * -------------------------------------------------- + * "/dev/vdc Disk GPT 30 GiB" + * "vdc1 New BIOS Boot Partition 8 MiB" + * "vdc3 swap New Swap Partition 1.5 GiB" + * "Unused space 3.49 GiB" + * "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" + * "Unused space 1 GiB" + * "vdc4 Linux Resized 514 MiB 1.5 GiB" + * "vdc5 / New Btrfs Partition 17.5 GiB" + * + * Device Mount point Details Size + * ------------------------------------------------------------------------- + * /dev/vdc Disk GPT 30 GiB + * vdc1 New BIOS Boot Partition 8 MiB + * vdc3 swap New Swap Partition 1.5 GiB + * Unused space 3.49 GiB + * vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB + * Unused space 1 GiB + * vdc4 Linux Resized 514 MiB 1.5 GiB + * vdc5 / New Btrfs Partition 17.5 GiB + * ------------------------------------------------------------------------- + */ + within(treegrid).getByRole("row", { name: "/dev/vdc Disk GPT 30 GiB" }); + within(treegrid).getByRole("row", { name: "vdc1 New BIOS Boot Partition 8 MiB" }); + within(treegrid).getByRole("row", { name: "vdc3 swap New Swap Partition 1.5 GiB" }); + within(treegrid).getByRole("row", { name: "Unused space 3.49 GiB" }); + within(treegrid).getByRole("row", { name: "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" }); + within(treegrid).getByRole("row", { name: "Unused space 1 GiB" }); + within(treegrid).getByRole("row", { name: "vdc4 Linux Resized 514 MiB 1.5 GiB" }); + within(treegrid).getByRole("row", { name: "vdc5 / New Btrfs Partition 17.5 GiB" }); + }); + + it("renders a button for opening the planned actions dialog", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /planned actions/ }); + + await user.click(button); + + screen.getByRole("dialog", { name: "Planned Actions" }); + }); + }); +}); diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index 8a99b0180d..063d513dab 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -24,8 +24,9 @@ export { default as ProposalPageMenu } from "./ProposalPageMenu"; export { default as ProposalSettingsSection } from "./ProposalSettingsSection"; export { default as ProposalSpacePolicyField } from "./ProposalSpacePolicyField"; export { default as ProposalDeviceSection } from "./ProposalDeviceSection"; -export { default as ProposalActionsSection } from "./ProposalActionsSection"; export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo"; +export { default as ProposalActionsDialog } from "./ProposalActionsDialog"; +export { default as ProposalResultSection } from "./ProposalResultSection"; export { default as ProposalVolumes } from "./ProposalVolumes"; export { default as DASDPage } from "./DASDPage"; export { default as DASDTable } from "./DASDTable"; diff --git a/web/src/components/storage/test-data/full-result-example.js b/web/src/components/storage/test-data/full-result-example.js new file mode 100644 index 0000000000..9013994320 --- /dev/null +++ b/web/src/components/storage/test-data/full-result-example.js @@ -0,0 +1,1446 @@ +export const settings = { + "bootDevice": "/dev/vdc", + "lvm": false, + "spacePolicy": "custom", + "spaceActions": [ + { + "device": "/dev/vdc3", + "action": "force_delete" + }, + { + "device": "/dev/vdc4", + "action": "resize" + }, + { + "device": "/dev/vdc1", + "action": "force_delete" + } + ], + "systemVGDevices": [], + "encryptionPassword": "", + "encryptionMethod": "luks2", + "volumes": [ + { + "mountPath": "/", + "fsType": "Btrfs", + "minSize": 18790481920, + "autoSize": true, + "snapshots": true, + "transactional": false, + "outline": { + "required": true, + "fsTypes": [ + "Btrfs", + "Ext2", + "Ext3", + "Ext4", + "XFS" + ], + "supportAutoSize": true, + "snapshotsConfigurable": true, + "snapshotsAffectSizes": true, + "sizeRelevantVolumes": [ + "/home" + ] + } + }, + { + "mountPath": "swap", + "fsType": "Swap", + "minSize": 1610612736, + "maxSize": 1610612736, + "autoSize": false, + "snapshots": false, + "transactional": false, + "outline": { + "required": false, + "fsTypes": [ + "Swap" + ], + "supportAutoSize": false, + "snapshotsConfigurable": false, + "snapshotsAffectSizes": false, + "sizeRelevantVolumes": [] + } + } + ], + "installationDevices": [ + { + "sid": 70, + "name": "/dev/vdc", + "description": "Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 32212254720, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0" + ], + "partitionTable": { + "type": "gpt", + "partitions": [ + { + "sid": 78, + "name": "/dev/vdc1", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 5368709120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part1" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 79, + "name": "/dev/vdc2", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 10487808, + "size": 5368709120, + "recoverableSize": 0, + "systems": [ + "openSUSE Leap 15.2", + "Fedora 10.30" + ], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 80, + "name": "/dev/vdc3", + "description": "XFS Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 20973568, + "size": 1073741824, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part3" + ], + "isEFI": false, + "filesystem": { + "sid": 92, + "type": "xfs" + } + }, + { + "sid": 81, + "name": "/dev/vdc4", + "description": "Linux", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 23070720, + "size": 2147483648, + "recoverableSize": 2147483136, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part4" + ], + "isEFI": false + } + ], + "unpartitionedSize": 18253611008, + "unusedSlots": [ + { + "start": 27265024, + "size": 18252545536 + } + ] + } + } + ] +}; + +export const devices = { + "system": [ + { + "sid": 71, + "name": "/dev/vda", + "description": "Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 53687091200, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0" + ], + "partitionTable": { + "type": "gpt", + "partitions": [ + { + "sid": 83, + "name": "/dev/vda1", + "description": "BIOS Boot Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 8388608, + "recoverableSize": 8388096, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part1" + ], + "isEFI": false + }, + { + "sid": 84, + "name": "/dev/vda2", + "description": "PV of system", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 53677637120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "physical_volume", + "deviceNames": [ + "/dev/system" + ] + } + } + ], + "unpartitionedSize": 1065472, + "unusedSlots": [] + } + }, + { + "sid": 69, + "name": "/dev/vdb", + "description": "Ext4 Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 5368709120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:08:00.0" + ], + "filesystem": { + "sid": 87, + "type": "ext4" + } + }, + { + "sid": 70, + "name": "/dev/vdc", + "description": "Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 32212254720, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0" + ], + "partitionTable": { + "type": "gpt", + "partitions": [ + { + "sid": 78, + "name": "/dev/vdc1", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 5368709120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part1" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 79, + "name": "/dev/vdc2", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 10487808, + "size": 5368709120, + "recoverableSize": 0, + "systems": [ + "openSUSE Leap 15.2", + "Fedora 10.30" + ], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 80, + "name": "/dev/vdc3", + "description": "XFS Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 20973568, + "size": 1073741824, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part3" + ], + "isEFI": false, + "filesystem": { + "sid": 92, + "type": "xfs" + } + }, + { + "sid": 81, + "name": "/dev/vdc4", + "description": "Linux", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 23070720, + "size": 2147483648, + "recoverableSize": 2147483136, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part4" + ], + "isEFI": false + } + ], + "unpartitionedSize": 18253611008, + "unusedSlots": [ + { + "start": 27265024, + "size": 18252545536 + } + ] + } + }, + { + "sid": 72, + "name": "/dev/md0", + "description": "Disk", + "isDrive": false, + "type": "md", + "level": "raid0", + "uuid": "644aeee1:5f5b946a:4da99758:3f85b3ea", + "devices": [ + { + "sid": 78, + "name": "/dev/vdc1", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 5368709120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part1" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 79, + "name": "/dev/vdc2", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 10487808, + "size": 5368709120, + "recoverableSize": 0, + "systems": [ + "openSUSE Leap 15.2", + "Fedora 10.30" + ], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + } + ], + "active": true, + "encrypted": false, + "start": 0, + "size": 10737287168, + "recoverableSize": 0, + "systems": [], + "udevIds": [ + "md-uuid-644aeee1:5f5b946a:4da99758:3f85b3ea" + ], + "udevPaths": [], + "partitionTable": { + "type": "gpt", + "partitions": [ + { + "sid": 86, + "name": "/dev/md0p1", + "description": "Ext4 Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 2147483648, + "recoverableSize": 2040147968, + "systems": [], + "udevIds": [ + "md-uuid-644aeee1:5f5b946a:4da99758:3f85b3ea-part1" + ], + "udevPaths": [], + "isEFI": false, + "filesystem": { + "sid": 93, + "type": "ext4" + } + } + ], + "unpartitionedSize": 8589803520, + "unusedSlots": [ + { + "start": 4196352, + "size": 8588738048 + } + ] + } + }, + { + "sid": 73, + "name": "/dev/system", + "description": "LVM", + "isDrive": false, + "type": "lvmVg", + "size": 53674508288, + "physicalVolumes": [ + { + "sid": 84, + "name": "/dev/vda2", + "description": "PV of system", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 53677637120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "physical_volume", + "deviceNames": [ + "/dev/system" + ] + } + } + ], + "logicalVolumes": [ + { + "sid": 75, + "name": "/dev/system/root", + "description": "Ext4 LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 51527024640, + "recoverableSize": 30647779328, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 88, + "type": "ext4", + "mountPath": "/" + } + }, + { + "sid": 76, + "name": "/dev/system/swap", + "description": "Swap LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 2147483648, + "recoverableSize": 2143289344, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 90, + "type": "swap", + "mountPath": "swap" + } + } + ] + }, + { + "sid": 75, + "name": "/dev/system/root", + "description": "Ext4 LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 51527024640, + "recoverableSize": 30647779328, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 88, + "type": "ext4", + "mountPath": "/" + } + }, + { + "sid": 76, + "name": "/dev/system/swap", + "description": "Swap LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 2147483648, + "recoverableSize": 2143289344, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 90, + "type": "swap", + "mountPath": "swap" + } + }, + { + "sid": 83, + "name": "/dev/vda1", + "description": "BIOS Boot Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 8388608, + "recoverableSize": 8388096, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part1" + ], + "isEFI": false + }, + { + "sid": 84, + "name": "/dev/vda2", + "description": "PV of system", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 53677637120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "physical_volume", + "deviceNames": [ + "/dev/system" + ] + } + }, + { + "sid": 78, + "name": "/dev/vdc1", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 5368709120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part1" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 79, + "name": "/dev/vdc2", + "description": "Part of md0", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 10487808, + "size": 5368709120, + "recoverableSize": 0, + "systems": [ + "openSUSE Leap 15.2", + "Fedora 10.30" + ], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "md_device", + "deviceNames": [ + "/dev/md0" + ] + } + }, + { + "sid": 80, + "name": "/dev/vdc3", + "description": "XFS Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 20973568, + "size": 1073741824, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part3" + ], + "isEFI": false, + "filesystem": { + "sid": 92, + "type": "xfs" + } + }, + { + "sid": 81, + "name": "/dev/vdc4", + "description": "Linux", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 23070720, + "size": 2147483648, + "recoverableSize": 2147483136, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part4" + ], + "isEFI": false + }, + { + "sid": 86, + "name": "/dev/md0p1", + "description": "Ext4 Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 2147483648, + "recoverableSize": 2040147968, + "systems": [], + "udevIds": [ + "md-uuid-644aeee1:5f5b946a:4da99758:3f85b3ea-part1" + ], + "udevPaths": [], + "isEFI": false, + "filesystem": { + "sid": 93, + "type": "ext4" + } + } + ], + "staging": [ + { + "sid": 71, + "name": "/dev/vda", + "description": "Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 53687091200, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0" + ], + "partitionTable": { + "type": "gpt", + "partitions": [ + { + "sid": 83, + "name": "/dev/vda1", + "description": "BIOS Boot Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 8388608, + "recoverableSize": 8388096, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part1" + ], + "isEFI": false + }, + { + "sid": 84, + "name": "/dev/vda2", + "description": "PV of system", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 53677637120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "physical_volume", + "deviceNames": [ + "/dev/system" + ] + } + } + ], + "unpartitionedSize": 1065472, + "unusedSlots": [] + } + }, + { + "sid": 69, + "name": "/dev/vdb", + "description": "Ext4 Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 5368709120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:08:00.0" + ], + "filesystem": { + "sid": 87, + "type": "ext4" + } + }, + { + "sid": 70, + "name": "/dev/vdc", + "description": "Disk", + "isDrive": true, + "type": "disk", + "vendor": "", + "model": "Disk", + "driver": [ + "virtio-pci", + "virtio_blk" + ], + "bus": "None", + "busId": "", + "transport": "unknown", + "sdCard": false, + "dellBOSS": false, + "active": true, + "encrypted": false, + "start": 0, + "size": 32212254720, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0" + ], + "partitionTable": { + "type": "gpt", + "partitions": [ + { + "sid": 79, + "name": "/dev/vdc2", + "description": "Linux RAID", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 10487808, + "size": 5368709120, + "recoverableSize": 5368708608, + "systems": [ + "openSUSE Leap 15.2", + "Fedora 10.30" + ], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part2" + ], + "isEFI": false + }, + { + "sid": 81, + "name": "/dev/vdc4", + "description": "Linux", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 23070720, + "size": 1608515584, + "recoverableSize": 1608515072, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part4" + ], + "isEFI": false + }, + { + "sid": 459, + "name": "/dev/vdc1", + "description": "BIOS Boot Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 8388608, + "recoverableSize": 8388096, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part1" + ], + "isEFI": false + }, + { + "sid": 460, + "name": "/dev/vdc3", + "description": "Swap Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 1610612736, + "recoverableSize": 1610571776, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part3" + ], + "isEFI": false, + "filesystem": { + "sid": 461, + "type": "swap", + "mountPath": "swap" + } + }, + { + "sid": 463, + "name": "/dev/vdc5", + "description": "Btrfs Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 26212352, + "size": 18791513600, + "recoverableSize": 18523078144, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part5" + ], + "isEFI": false, + "filesystem": { + "sid": 464, + "type": "btrfs", + "mountPath": "/" + } + } + ], + "unpartitionedSize": 4824515072, + "unusedSlots": [ + { + "start": 3164160, + "size": 3749707776 + }, + { + "start": 20973568, + "size": 1073741824 + } + ] + } + }, + { + "sid": 73, + "name": "/dev/system", + "description": "LVM", + "isDrive": false, + "type": "lvmVg", + "size": 53674508288, + "physicalVolumes": [ + { + "sid": 84, + "name": "/dev/vda2", + "description": "PV of system", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 53677637120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "physical_volume", + "deviceNames": [ + "/dev/system" + ] + } + } + ], + "logicalVolumes": [ + { + "sid": 75, + "name": "/dev/system/root", + "description": "Ext4 LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 51527024640, + "recoverableSize": 30647779328, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 88, + "type": "ext4", + "mountPath": "/" + } + }, + { + "sid": 76, + "name": "/dev/system/swap", + "description": "Swap LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 2147483648, + "recoverableSize": 2143289344, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 90, + "type": "swap", + "mountPath": "swap" + } + } + ] + }, + { + "sid": 75, + "name": "/dev/system/root", + "description": "Ext4 LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 51527024640, + "recoverableSize": 30647779328, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 88, + "type": "ext4", + "mountPath": "/" + } + }, + { + "sid": 76, + "name": "/dev/system/swap", + "description": "Swap LV", + "isDrive": false, + "type": "lvmLv", + "active": true, + "encrypted": false, + "start": 0, + "size": 2147483648, + "recoverableSize": 2143289344, + "systems": [], + "udevIds": [], + "udevPaths": [], + "filesystem": { + "sid": 90, + "type": "swap", + "mountPath": "swap" + } + }, + { + "sid": 83, + "name": "/dev/vda1", + "description": "BIOS Boot Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 8388608, + "recoverableSize": 8388096, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part1" + ], + "isEFI": false + }, + { + "sid": 84, + "name": "/dev/vda2", + "description": "PV of system", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 53677637120, + "recoverableSize": 0, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:04:00.0-part2" + ], + "isEFI": false, + "component": { + "type": "physical_volume", + "deviceNames": [ + "/dev/system" + ] + } + }, + { + "sid": 79, + "name": "/dev/vdc2", + "description": "Linux RAID", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 10487808, + "size": 5368709120, + "recoverableSize": 5368708608, + "systems": [ + "openSUSE Leap 15.2", + "Fedora 10.30" + ], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part2" + ], + "isEFI": false + }, + { + "sid": 81, + "name": "/dev/vdc4", + "description": "Linux", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 23070720, + "size": 1608515584, + "recoverableSize": 1608515072, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part4" + ], + "isEFI": false + }, + { + "sid": 459, + "name": "/dev/vdc1", + "description": "BIOS Boot Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 2048, + "size": 8388608, + "recoverableSize": 8388096, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part1" + ], + "isEFI": false + }, + { + "sid": 460, + "name": "/dev/vdc3", + "description": "Swap Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 18432, + "size": 1610612736, + "recoverableSize": 1610571776, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part3" + ], + "isEFI": false, + "filesystem": { + "sid": 461, + "type": "swap", + "mountPath": "swap" + } + }, + { + "sid": 463, + "name": "/dev/vdc5", + "description": "Btrfs Partition", + "isDrive": false, + "type": "partition", + "active": true, + "encrypted": false, + "start": 26212352, + "size": 18791513600, + "recoverableSize": 18523078144, + "systems": [], + "udevIds": [], + "udevPaths": [ + "pci-0000:09:00.0-part5" + ], + "isEFI": false, + "filesystem": { + "sid": 464, + "type": "btrfs", + "mountPath": "/" + } + } + ] +}; + +export const actions = [ + { + "device": 86, + "text": "Delete partition /dev/md0p1 (2.00 GiB)", + "subvol": false, + "delete": true + }, + { + "device": 72, + "text": "Delete RAID0 /dev/md0 (10.00 GiB)", + "subvol": false, + "delete": true + }, + { + "device": 80, + "text": "Delete partition /dev/vdc3 (1.00 GiB)", + "subvol": false, + "delete": true + }, + { + "device": 78, + "text": "Delete partition /dev/vdc1 (5.00 GiB)", + "subvol": false, + "delete": true + }, + { + "device": 81, + "text": "Shrink partition /dev/vdc4 from 2.00 GiB to 1.50 GiB", + "subvol": false, + "delete": false + }, + { + "device": 459, + "text": "Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition", + "subvol": false, + "delete": false + }, + { + "device": 460, + "text": "Create partition /dev/vdc3 (1.50 GiB) for swap", + "subvol": false, + "delete": false + }, + { + "device": 463, + "text": "Create partition /dev/vdc5 (17.50 GiB) for / with btrfs", + "subvol": false, + "delete": false + }, + { + "device": 467, + "text": "Create subvolume @ on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 482, + "text": "Create subvolume @/boot/grub2/x86_64-efi on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 480, + "text": "Create subvolume @/boot/grub2/i386-pc on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 478, + "text": "Create subvolume @/var on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 476, + "text": "Create subvolume @/usr/local on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 474, + "text": "Create subvolume @/srv on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 472, + "text": "Create subvolume @/root on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 470, + "text": "Create subvolume @/opt on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + }, + { + "device": 468, + "text": "Create subvolume @/home on /dev/vdc5 (17.50 GiB)", + "subvol": true, + "delete": false + } +]; diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js index 082756b08c..7817acd4e1 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.js @@ -19,6 +19,7 @@ * find current contact information at www.suse.com. */ +// @ts-check // cspell:ignore xbytes import xbytes from "xbytes"; @@ -27,7 +28,8 @@ import { N_ } from "~/i18n"; /** * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/clients/storage").StorageDevice} StorageDevice + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import ("~/client/storage").PartitionSlot} PartitionSlot */ /** @@ -121,7 +123,7 @@ const deviceSize = (size) => { const parseToBytes = (size) => { if (!size || size === undefined || size === "") return 0; - const value = xbytes.parseSize(size, { iec: true }) || parseInt(size); + const value = xbytes.parseSize(size.toString(), { iec: true }) || parseInt(size.toString()); // Avoid decimals resulting from the conversion. D-Bus iface only accepts integer return Math.trunc(value); @@ -140,6 +142,33 @@ const deviceLabel = (device) => { return size ? `${name}, ${deviceSize(size)}` : name; }; +/** + * Sorted list of children devices (i.e., partitions and unused slots or logical volumes). + * @function + * + * @note This method could be directly provided by the device object. For now, the method is kept + * here because the elements considered as children (e.g., partitions + unused slots) is not a + * semantic storage concept but a helper for UI components. + * + * @param {StorageDevice} device + * @returns {(StorageDevice|PartitionSlot)[]} + */ +const deviceChildren = (device) => { + const partitionTableChildren = (partitionTable) => { + const { partitions, unusedSlots } = partitionTable; + const children = partitions.concat(unusedSlots); + return children.sort((a, b) => a.start < b.start ? -1 : 1); + }; + + const lvmVgChildren = (lvmVg) => { + return lvmVg.logicalVolumes.sort((a, b) => a.name < b.name ? -1 : 1); + }; + + if (device.partitionTable) return partitionTableChildren(device.partitionTable); + if (device.type === "lvmVg") return lvmVgChildren(device); + return []; +}; + /** * Checks if volume uses given fs. This method works same as in backend * case insensitive. @@ -193,6 +222,7 @@ export { SIZE_METHODS, SIZE_UNITS, deviceLabel, + deviceChildren, deviceSize, parseToBytes, splitSize, diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.js index e5eb3cf0aa..c144b123a0 100644 --- a/web/src/components/storage/utils.test.js +++ b/web/src/components/storage/utils.test.js @@ -22,6 +22,7 @@ import { deviceSize, deviceLabel, + deviceChildren, parseToBytes, splitSize, hasFS, @@ -49,6 +50,66 @@ describe("deviceLabel", () => { }); }); +describe("deviceChildren", () => { + let device; + + describe("if the device has partition table", () => { + beforeEach(() => { + device = { + sid: 60, + partitionTable: { + partitions: [ + { sid: 61 }, + { sid: 62 }, + ], + unusedSlots: [ + { start: 1, size: 1024 }, + { start: 2345, size: 512 } + ] + } + }; + }); + + it("returns the partitions and unused slots", () => { + const children = deviceChildren(device); + expect(children.length).toEqual(4); + device.partitionTable.partitions.forEach(p => expect(children).toContainEqual(p)); + device.partitionTable.unusedSlots.forEach(s => expect(children).toContainEqual(s)); + }); + }); + + describe("if the device is a LVM volume group", () => { + beforeEach(() => { + device = { + sid: 60, + type: "lvmVg", + logicalVolumes: [ + { sid: 61 }, + { sid: 62 }, + { sid: 63 } + ] + }; + }); + + it("returns the logical volumes", () => { + const children = deviceChildren(device); + expect(children.length).toEqual(3); + device.logicalVolumes.forEach(l => expect(children).toContainEqual(l)); + }); + }); + + describe("if the device has neither partition table nor logical volumes", () => { + beforeEach(() => { + device = { sid: 60 }; + }); + + it("returns an empty list", () => { + const children = deviceChildren(device); + expect(children.length).toEqual(0); + }); + }); +}); + describe("parseToBytes", () => { it("returns bytes from given input", () => { expect(parseToBytes(1024)).toEqual(1024); diff --git a/web/src/utils.js b/web/src/utils.js index 5969268cf1..f9645ab238 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -66,6 +66,28 @@ const partition = (collection, filter) => { return [pass, fail]; }; +/** + * Generates a new array without null and undefined values. + * @function + * + * @param {Array} collection + * @returns {Array} + */ +function compact(collection) { + return collection.filter(e => e !== null && e !== undefined); +} + +/** + * Generates a new array without duplicates. + * @function + * + * @param {Array} collection + * @returns {Array} + */ +function uniq(collection) { + return [...new Set(collection)]; +} + /** * Simple utility function to help building className conditionally * @@ -355,6 +377,8 @@ export { noop, isObject, partition, + compact, + uniq, classNames, useCancellablePromise, useLocalStorage, diff --git a/web/src/utils.test.js b/web/src/utils.test.js index 8be48355cb..5d340828f8 100644 --- a/web/src/utils.test.js +++ b/web/src/utils.test.js @@ -20,7 +20,7 @@ */ import { - classNames, partition, noop, toValidationError, + classNames, partition, compact, uniq, noop, toValidationError, localConnection, remoteConnection, isObject } from "./utils"; @@ -41,6 +41,22 @@ describe("partition", () => { }); }); +describe("compact", () => { + it("removes null and undefined values", () => { + expect(compact([])).toEqual([]); + expect(compact([undefined, null, "", 0, 1, NaN, false, true])) + .toEqual(["", 0, 1, NaN, false, true]); + }); +}); + +describe("uniq", () => { + it("removes duplicated values", () => { + expect(uniq([])).toEqual([]); + expect(uniq([undefined, null, null, 0, 1, NaN, false, true, false, "test"])) + .toEqual([undefined, null, 0, 1, NaN, false, true, "test"]); + }); +}); + describe("classNames", () => { it("join given arguments, ignoring falsy values", () => { expect(classNames(